From df8c7493f748f240af6a8579c625e716b9181721 Mon Sep 17 00:00:00 2001 From: Neo Date: Tue, 2 Jun 2026 13:33:37 +1200 Subject: [PATCH 01/73] go2updates --- asg_client/app/build.gradle | 24 +- .../core/BluetoothManagerFactory.java | 16 +- .../managers/InmoGo2BluetoothManager.java | 386 ++++ .../hardware/core/HardwareManagerFactory.java | 10 + .../managers/InmoGo2HardwareManager.java | 363 ++++ .../services/RtmpStreamingService.java | 146 +- .../services/SrtStreamingService.java | 1630 ++++++++--------- .../service/utils/ServiceUtils.java | 71 +- asg_client/settings.gradle | 11 +- .../types/src/capabilities/inmo-go2.ts | 110 ++ cloud/packages/types/src/enums.ts | 1 + cloud/packages/types/src/hardware.ts | 4 +- mobile/app.config.ts | 5 + .../ios/Source/DeviceManager.swift | 6 + .../ios/Source/sgcs/InmoGo2.swift | 1244 +++++++++++++ .../ios/Source/utils/Constants.swift | 2 + .../bluetooth-sdk/scripts/with-node.sh | 44 + mobile/modules/crust/scripts/with-node.sh | 44 + mobile/modules/island/scripts/with-node.sh | 44 + mobile/src/utils/getGlassesImage.tsx | 4 + 20 files changed, 3246 insertions(+), 919 deletions(-) create mode 100644 asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/InmoGo2BluetoothManager.java create mode 100644 asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/managers/InmoGo2HardwareManager.java create mode 100644 cloud/packages/types/src/capabilities/inmo-go2.ts create mode 100644 mobile/modules/bluetooth-sdk/ios/Source/sgcs/InmoGo2.swift create mode 100644 mobile/modules/bluetooth-sdk/scripts/with-node.sh create mode 100644 mobile/modules/crust/scripts/with-node.sh create mode 100644 mobile/modules/island/scripts/with-node.sh 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/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..6a25281f93 --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/InmoGo2BluetoothManager.java @@ -0,0 +1,386 @@ +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 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; + } + + // 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); + } + + // ----------------------------------------------------------------------- + // 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"); + notifyDataReceived(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); + } + }; +} 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/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/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..4893db8430 --- /dev/null +++ b/cloud/packages/types/src/capabilities/inmo-go2.ts @@ -0,0 +1,110 @@ +/** + * @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: false, + display: null, + + // 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/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/sgcs/InmoGo2.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/InmoGo2.swift new file mode 100644 index 0000000000..2ae8f9431b --- /dev/null +++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/InmoGo2.swift @@ -0,0 +1,1244 @@ +// +// 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 key + // ----------------------------------------------------------------------- + + private let PREFS_DEVICE_NAME = "InmoGo2LastConnectedDeviceName" + + // ----------------------------------------------------------------------- + // 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(_: String, _: String) {} + func setHeadUpAngle(_: Int) {} + func getBatteryStatus() {} + func setBrightness(_: Int, autoMode _: Bool) {} + func clearDisplay() {} + func sendTextWall(_: String) {} + 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 + // ----------------------------------------------------------------------- + + /// Camera LED — Go2 uses torch as recording indicator; no separate LED protocol. + func sendButtonCameraLedSetting() { + let enabled = GlassesStore.shared.get("core", "button_camera_led") as? Bool ?? true + sendJson(["type": "button_camera_led", "enabled": enabled], wakeUp: true) + } + + /// Camera FOV — send to ASG client for lens configuration. + 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) + } + + /// No-arg variant required by protocol — reads value from GlassesStore. + func sendButtonMaxRecordingTime() { + let maxTime = GlassesStore.shared.get("core", "button_max_recording_time") as? Int ?? 10 + sendButtonMaxRecordingTime(maxTime) + } + + /// RGB LED control — Go2 has no RGB LED; no-op. + 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") + } + + /// Incident log upload — forward incidentId to ASG client for on-device log collection. + 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) + } + + /// Gallery status query — request current photo/video counts from ASG client. + func queryGalleryStatus() { + sendJson(["type": "query_gallery_status"], wakeUp: true) + } + + /// Gallery mode — tell ASG client whether to save media to gallery. + 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) + // Go2 mic is handled entirely on the Android side via ASG client; + // we simply notify it of the desired state through the standard JSON channel. + 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) // 100 ms warm-up + } + UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME) + 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] + ) + } + + // Check for already-connected peripherals first + let connectedPeripherals = centralManager!.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 let saved = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME), + saved == name + { + connectToDevice(peripheral) + return + } + } + } + startScan() + } + + func getConnectedBluetoothName() -> String? { + return connectedPeripheral?.name + } + + func forget() { + Bridge.log("GO2: forget()") + if isScanning { stopScan(); emitStopScanEvent() } + 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") + } + + /// Returns true if the BLE advertisement name belongs to an INMO Go2. + /// The Go2 advertises as "INMO GO2" (confirmed). We also accept a bare + /// "INMO_GO2" in case the ASG client overrides the name in future builds. + 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 } + 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) + // ----------------------------------------------------------------------- + + /// Encode `json` as UTF-8 JSON and queue it for BLE transmission to the Go2. + /// + /// The Go2 ASG client speaks plain JSON over the 4862 write characteristic. + /// Unlike MentraLive (which has a BES2700 chip requiring `##...$$` framing), + /// no binary wrapper is applied here. + 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 + // ----------------------------------------------------------------------- + + /// All data received from the Go2 is plain UTF-8 JSON starting with `{`. + private func processReceivedData(_ data: Data) { + guard !data.isEmpty else { return } + let bytes = [UInt8](data) + + // JSON starts with '{' + 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]) { + // Clear pending ACK if this is the acknowledgement we're waiting for + 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 + } + + // Send ACK back to glasses for messages that carry a message ID + 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() + + // Clear stale version fields + 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) + } + + // ----------------------------------------------------------------------- + // 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]) + } + + // Touch event reporting + 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") + 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(_: CBCentralManager, didConnect peripheral: CBPeripheral) { + Bridge.log("GO2: ✅ GATT connected — discovering services…") + stopConnectionTimeout() + isConnecting = false + connectedPeripheral = peripheral + 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 } + // All incoming data arrives on TX (4861 Notify) + processReceivedData(data) + } + + func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) { + if let error { + Bridge.log("GO2: Write error: \(error.localizedDescription)") + } + // ACK write success — queue pump will clear pending if needed after glasses ACK + } + + 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/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/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/utils/getGlassesImage.tsx b/mobile/src/utils/getGlassesImage.tsx index 2df8ec00e3..e20e968c06 100644 --- a/mobile/src/utils/getGlassesImage.tsx +++ b/mobile/src/utils/getGlassesImage.tsx @@ -15,6 +15,10 @@ export const getGlassesImage = (glasses: string | null) => { return require("../../assets/glasses/mentra_live/mentra_live.png") case "inmo_air": return require("../../assets/glasses/inmo_air.png") + case "INMO Go2": + case "inmo_go2": + // TODO: Replace with dedicated inmo_go2.png asset when available + return require("../../assets/glasses/inmo_air.png") case "tcl_rayneo_x_two": return require("../../assets/glasses/tcl_rayneo_x_two.png") case "Vuzix_shield": From 01f1356ba2726ded9cdfb7323dbc35ecba1be290 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:37:53 +1200 Subject: [PATCH 02/73] Add INMO Go2 hardware capabilities profile This file defines the hardware capabilities for the INMO Go2 smart glasses, including specifications for the camera, display, microphone, speaker, IMU, buttons, light, power, and WiFi. --- .github/workflows/ios-unsigned-ipa.yml | 110 +++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .github/workflows/ios-unsigned-ipa.yml diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml new file mode 100644 index 0000000000..4893db8430 --- /dev/null +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -0,0 +1,110 @@ +/** + * @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: false, + display: null, + + // 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, +}; From c6bae2e6210678990870a6ca07608a2a4c0a0ab4 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:41:37 +1200 Subject: [PATCH 03/73] Update iOS unsigned IPA workflow configuration --- .github/workflows/ios-unsigned-ipa.yml | 169 ++++++++++--------------- 1 file changed, 70 insertions(+), 99 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 4893db8430..8a48ad0d7f 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,110 +1,81 @@ -/** - * @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) - */ +name: iOS Unsigned IPA (Sideload Build) -import type { Capabilities } from "../hardware"; +on: + workflow_dispatch: + push: + branches: + - dev -/** - * INMO Go2 capability profile - */ -export const inmoGo2: Capabilities = { - modelName: "INMO Go2", +jobs: + build: + runs-on: macos-15 - // 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 }, - ], - }, - }, + steps: + - name: Checkout code + uses: actions/checkout@v4 - // Display — monocular waveguide AR overlay; no bitmap rendering from phone side - hasDisplay: false, - display: null, + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest - // Microphone — single built-in mic; LC3 audio not supported (no BES chip) - hasMicrophone: true, - microphone: { - count: 1, - hasVAD: false, - }, + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" - // Speaker — single speaker - hasSpeaker: true, - speaker: { - count: 1, - isPrivate: false, - }, + - name: Install dependencies + working-directory: ./mobile + run: bun install --no-frozen-lockfile - // IMU — standard Android 6-axis (accelerometer + gyroscope) - hasIMU: true, - imu: { - axisCount: 6, - hasAccelerometer: true, - hasCompass: false, - hasGyroscope: true, - }, + - name: Setup environment + working-directory: ./mobile + run: cp .env.example .env - // 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, - }, - ], - }, + - name: Run Expo prebuild + working-directory: ./mobile + run: bun expo prebuild --platform ios - // 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", - }, - ], - }, + - name: Setup Sherpa Onnx (optional, non-blocking) + working-directory: ./mobile + run: | + chmod +x scripts/setup-sherpa-onnx-optional.sh + ./scripts/setup-sherpa-onnx-optional.sh || true - // Power — no external battery case - power: { - hasExternalBattery: false, - }, + - name: Install CocoaPods dependencies + working-directory: ./mobile/ios + run: pod install - // WiFi — dual-band supported (ro.wifi.sup_sprd.support5G = true) - hasWifi: true, -}; + - name: Build unsigned .app for device + id: xcode-build + working-directory: ./mobile/ios + env: + SENTRY_DISABLE_AUTO_UPLOAD: "true" + run: | + xcodebuild \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build-device \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Package .app into unsigned IPA + run: | + APP_PATH="mobile/ios/build-device/Build/Products/Release-iphoneos/Mentra.app" + IPA_PATH="mobile/ios/Mentra-unsigned.ipa" + mkdir -p /tmp/Payload + cp -r "$APP_PATH" /tmp/Payload/Mentra.app + cd /tmp + zip -r "$GITHUB_WORKSPACE/$IPA_PATH" Payload + echo "IPA created at $IPA_PATH" + ls -lh "$GITHUB_WORKSPACE/$IPA_PATH" + + - name: Upload unsigned IPA artifact + uses: actions/upload-artifact@v4 + with: + name: Mentra-unsigned-ipa + path: mobile/ios/Mentra-unsigned.ipa + retention-days: 14 From c4f1fe8ca1ec2f021dd3291c9d9039bc381ee356 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:10:41 +1200 Subject: [PATCH 04/73] Update iOS workflow to select Xcode 16.2 Added steps to select Xcode version and show versions. --- .github/workflows/ios-unsigned-ipa.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 8a48ad0d7f..316221d469 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -14,8 +14,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Select Xcode 16.2 + run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + + - name: Show Xcode and Swift version + run: | + xcodebuild -version + swift --version + - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -47,7 +55,6 @@ jobs: run: pod install - name: Build unsigned .app for device - id: xcode-build working-directory: ./mobile/ios env: SENTRY_DISABLE_AUTO_UPLOAD: "true" @@ -58,9 +65,13 @@ jobs: -configuration Release \ -destination 'generic/platform=iOS' \ -derivedDataPath build-device \ + SWIFT_VERSION=5 \ + SWIFT_STRICT_CONCURRENCY=minimal \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ + (echo "=== FULL ERROR LOG ===" && grep -E "error:|warning:|Build FAILED" /tmp/xcodebuild.log | tail -50 && exit 1) - name: Package .app into unsigned IPA run: | @@ -79,3 +90,11 @@ jobs: name: Mentra-unsigned-ipa path: mobile/ios/Mentra-unsigned.ipa retention-days: 14 + + - name: Upload build log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 From 428a6980eae6c13fce34ace476fd13082d42cb39 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:26:54 +1200 Subject: [PATCH 05/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 316221d469..aaacc726bd 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -62,7 +62,7 @@ jobs: xcodebuild \ -workspace Mentra.xcworkspace \ -scheme Mentra \ - -configuration Release \ + -configuration Debug \ -destination 'generic/platform=iOS' \ -derivedDataPath build-device \ SWIFT_VERSION=5 \ @@ -70,18 +70,33 @@ jobs: CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + COPY_PHASE_STRIP=NO \ + STRIP_INSTALLED_PRODUCT=NO \ 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ - (echo "=== FULL ERROR LOG ===" && grep -E "error:|warning:|Build FAILED" /tmp/xcodebuild.log | tail -50 && exit 1) + (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -50 && exit 1) + + - name: Verify Info.plist exists + run: | + APP_PATH="mobile/ios/build-device/Build/Products/Debug-iphoneos/Mentra.app" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "ERROR: Info.plist missing from bundle" + echo "Bundle contents:" + ls -la "$APP_PATH" + exit 1 + fi + echo "✅ Info.plist found" + echo "Bundle contents:" + ls -la "$APP_PATH" - name: Package .app into unsigned IPA run: | - APP_PATH="mobile/ios/build-device/Build/Products/Release-iphoneos/Mentra.app" + APP_PATH="mobile/ios/build-device/Build/Products/Debug-iphoneos/Mentra.app" IPA_PATH="mobile/ios/Mentra-unsigned.ipa" mkdir -p /tmp/Payload cp -r "$APP_PATH" /tmp/Payload/Mentra.app cd /tmp zip -r "$GITHUB_WORKSPACE/$IPA_PATH" Payload - echo "IPA created at $IPA_PATH" + echo "✅ IPA created" ls -lh "$GITHUB_WORKSPACE/$IPA_PATH" - name: Upload unsigned IPA artifact From a7dec3d7ed8ff4746e33f72eef354739c8273a11 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:41:40 +1200 Subject: [PATCH 06/73] Refactor iOS workflow to enhance build logging Removed Xcode and Swift version display step and added steps to list available schemes, targets, and find built .app. Adjusted error handling and output messages for better clarity. --- .github/workflows/ios-unsigned-ipa.yml | 48 ++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index aaacc726bd..92c054d4a0 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -17,11 +17,6 @@ jobs: - name: Select Xcode 16.2 run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - - name: Show Xcode and Swift version - run: | - xcodebuild -version - swift --version - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -54,6 +49,18 @@ jobs: working-directory: ./mobile/ios run: pod install + - name: List available schemes and targets + working-directory: ./mobile/ios + run: | + echo "=== SCHEMES ===" + xcodebuild -workspace Mentra.xcworkspace -list + echo "" + echo "=== WORKSPACE CONTENTS ===" + ls -la + echo "" + echo "=== XCODEPROJ TARGETS ===" + xcodebuild -project Mentra.xcodeproj -list 2>/dev/null || true + - name: Build unsigned .app for device working-directory: ./mobile/ios env: @@ -72,31 +79,36 @@ jobs: CODE_SIGNING_ALLOWED=NO \ COPY_PHASE_STRIP=NO \ STRIP_INSTALLED_PRODUCT=NO \ + ONLY_ACTIVE_ARCH=NO \ 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ - (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -50 && exit 1) + (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - name: Verify Info.plist exists + - name: Find built .app run: | - APP_PATH="mobile/ios/build-device/Build/Products/Debug-iphoneos/Mentra.app" - if [ ! -f "$APP_PATH/Info.plist" ]; then - echo "ERROR: Info.plist missing from bundle" - echo "Bundle contents:" - ls -la "$APP_PATH" + echo "=== Searching for .app bundles ===" + find mobile/ios/build-device -name "*.app" -type d 2>/dev/null + echo "" + echo "=== Full build-device structure ===" + find mobile/ios/build-device/Build/Products -maxdepth 3 2>/dev/null || echo "No Products folder found" + + - name: Package .app into unsigned IPA + run: | + # Find the actual .app location dynamically + APP_PATH=$(find mobile/ios/build-device/Build/Products -name "Mentra.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "ERROR: Could not find Mentra.app" exit 1 fi - echo "✅ Info.plist found" - echo "Bundle contents:" + echo "Found app at: $APP_PATH" + echo "Contents:" ls -la "$APP_PATH" - - name: Package .app into unsigned IPA - run: | - APP_PATH="mobile/ios/build-device/Build/Products/Debug-iphoneos/Mentra.app" IPA_PATH="mobile/ios/Mentra-unsigned.ipa" mkdir -p /tmp/Payload cp -r "$APP_PATH" /tmp/Payload/Mentra.app cd /tmp zip -r "$GITHUB_WORKSPACE/$IPA_PATH" Payload - echo "✅ IPA created" + echo "✅ IPA created at $IPA_PATH" ls -lh "$GITHUB_WORKSPACE/$IPA_PATH" - name: Upload unsigned IPA artifact From 4dde247852227e6c47f26d07befc711112aa8c5f Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:01:25 +1200 Subject: [PATCH 07/73] Rename workflow for SideStore build process --- .github/workflows/ios-unsigned-ipa.yml | 255 +++++++++++++------------ 1 file changed, 132 insertions(+), 123 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 92c054d4a0..728c42809c 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,127 +1,136 @@ -name: iOS Unsigned IPA (Sideload Build) +name: iOS IPA (SideStore Build) on: - workflow_dispatch: - push: - branches: - - dev +workflow_dispatch: +push: +branches: +- dev jobs: - build: - runs-on: macos-15 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Select Xcode 16.2 - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install dependencies - working-directory: ./mobile - run: bun install --no-frozen-lockfile - - - name: Setup environment - working-directory: ./mobile - run: cp .env.example .env - - - name: Run Expo prebuild - working-directory: ./mobile - run: bun expo prebuild --platform ios - - - name: Setup Sherpa Onnx (optional, non-blocking) - working-directory: ./mobile - run: | - chmod +x scripts/setup-sherpa-onnx-optional.sh - ./scripts/setup-sherpa-onnx-optional.sh || true - - - name: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: pod install - - - name: List available schemes and targets - working-directory: ./mobile/ios - run: | - echo "=== SCHEMES ===" - xcodebuild -workspace Mentra.xcworkspace -list - echo "" - echo "=== WORKSPACE CONTENTS ===" - ls -la - echo "" - echo "=== XCODEPROJ TARGETS ===" - xcodebuild -project Mentra.xcodeproj -list 2>/dev/null || true - - - name: Build unsigned .app for device - working-directory: ./mobile/ios - env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" - run: | - xcodebuild \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ - -configuration Debug \ - -destination 'generic/platform=iOS' \ - -derivedDataPath build-device \ - SWIFT_VERSION=5 \ - SWIFT_STRICT_CONCURRENCY=minimal \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - COPY_PHASE_STRIP=NO \ - STRIP_INSTALLED_PRODUCT=NO \ - ONLY_ACTIVE_ARCH=NO \ - 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ - (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - - name: Find built .app - run: | - echo "=== Searching for .app bundles ===" - find mobile/ios/build-device -name "*.app" -type d 2>/dev/null - echo "" - echo "=== Full build-device structure ===" - find mobile/ios/build-device/Build/Products -maxdepth 3 2>/dev/null || echo "No Products folder found" - - - name: Package .app into unsigned IPA - run: | - # Find the actual .app location dynamically - APP_PATH=$(find mobile/ios/build-device/Build/Products -name "Mentra.app" -type d | head -1) - if [ -z "$APP_PATH" ]; then - echo "ERROR: Could not find Mentra.app" - exit 1 - fi - echo "Found app at: $APP_PATH" - echo "Contents:" - ls -la "$APP_PATH" - - IPA_PATH="mobile/ios/Mentra-unsigned.ipa" - mkdir -p /tmp/Payload - cp -r "$APP_PATH" /tmp/Payload/Mentra.app - cd /tmp - zip -r "$GITHUB_WORKSPACE/$IPA_PATH" Payload - echo "✅ IPA created at $IPA_PATH" - ls -lh "$GITHUB_WORKSPACE/$IPA_PATH" - - - name: Upload unsigned IPA artifact - uses: actions/upload-artifact@v4 - with: - name: Mentra-unsigned-ipa - path: mobile/ios/Mentra-unsigned.ipa - retention-days: 14 - - - name: Upload build log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: xcodebuild-log - path: /tmp/xcodebuild.log - retention-days: 5 +build: +runs-on: macos-15 + +``` +steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode 16.2 + run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + working-directory: ./mobile + run: bun install --no-frozen-lockfile + + - name: Setup environment + working-directory: ./mobile + run: cp .env.example .env + + - name: Expo prebuild (generate iOS project) + 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: Install CocoaPods dependencies + working-directory: ./mobile/ios + run: pod install + + - name: Show available schemes (debugging) + working-directory: ./mobile/ios + run: | + echo "=== SCHEMES ===" + xcodebuild -workspace Mentra.xcworkspace -list + + - name: Build unsigned .app (Release for device) + working-directory: ./mobile/ios + env: + SENTRY_DISABLE_AUTO_UPLOAD: "true" + run: | + set -o pipefail + + xcodebuild \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + COPY_PHASE_STRIP=NO \ + STRIP_INSTALLED_PRODUCT=NO \ + ONLY_ACTIVE_ARCH=NO \ + 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color + + - name: Locate built .app + run: | + echo "=== Searching for .app ===" + APP_PATH=$(find mobile/ios/build/Build/Products -name "*.app" -type d | head -1) + + if [ -z "$APP_PATH" ]; then + echo "❌ ERROR: .app not found" + exit 1 + fi + + echo "✅ Found app at: $APP_PATH" + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + + - name: Verify app bundle (important for SideStore) + run: | + echo "=== App contents ===" + ls -la "$APP_PATH" + + echo "" + echo "=== Frameworks ===" + ls "$APP_PATH/Frameworks" || echo "No Frameworks folder" + + - name: Create IPA (SideStore-compatible) + run: | + echo "=== Packaging IPA ===" + + rm -rf Payload + mkdir Payload + + # Copy app into Payload + cp -r "$APP_PATH" Payload/App.app + + # Optional: placeholder provisioning file (helps some sideload tools) + touch Payload/App.app/embedded.mobileprovision + + # Zip into IPA + zip -r Mentra.ipa Payload + + # Move to project directory + mv Mentra.ipa mobile/ios/ + + echo "✅ IPA created:" + ls -lh mobile/ios/Mentra.ipa + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: Mentra-SideStore-IPA + path: mobile/ios/Mentra.ipa + retention-days: 14 + + - name: Upload build log (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 +``` From d41eb94d6676d53e34871d6997f1274ba7c24fdf Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:02:08 +1200 Subject: [PATCH 08/73] Fix formatting in ios-unsigned-ipa.yml Removed unnecessary code block formatting from the workflow. --- .github/workflows/ios-unsigned-ipa.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 728c42809c..4cca2b74d6 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -10,7 +10,6 @@ jobs: build: runs-on: macos-15 -``` steps: - name: Checkout repository uses: actions/checkout@v4 From ef03ac0b375bd1dfab9125c944bebe72274363bd Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:03:00 +1200 Subject: [PATCH 09/73] Fix retention-days configuration in workflow --- .github/workflows/ios-unsigned-ipa.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 4cca2b74d6..3837664343 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -132,4 +132,3 @@ steps: name: xcodebuild-log path: /tmp/xcodebuild.log retention-days: 5 -``` From d7a527c6356ca1cee4ec7ebcd91ba1c43365bfbc Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:05:20 +1200 Subject: [PATCH 10/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 3837664343..b5c0e2722e 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -10,6 +10,7 @@ jobs: build: runs-on: macos-15 +``` steps: - name: Checkout repository uses: actions/checkout@v4 @@ -103,16 +104,12 @@ steps: rm -rf Payload mkdir Payload - # Copy app into Payload cp -r "$APP_PATH" Payload/App.app - # Optional: placeholder provisioning file (helps some sideload tools) touch Payload/App.app/embedded.mobileprovision - # Zip into IPA zip -r Mentra.ipa Payload - # Move to project directory mv Mentra.ipa mobile/ios/ echo "✅ IPA created:" @@ -132,3 +129,4 @@ steps: name: xcodebuild-log path: /tmp/xcodebuild.log retention-days: 5 +``` From 42a42889376ff3e74ff2f0e2fc084b255ab2f6ac Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:05:59 +1200 Subject: [PATCH 11/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index b5c0e2722e..6d9db28a2f 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -10,7 +10,7 @@ jobs: build: runs-on: macos-15 -``` + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -129,4 +129,3 @@ steps: name: xcodebuild-log path: /tmp/xcodebuild.log retention-days: 5 -``` From d676a88ab709f206b5d9beb0c79c5755bf46f565 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:09:01 +1200 Subject: [PATCH 12/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 226 +++++++++++-------------- 1 file changed, 100 insertions(+), 126 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 6d9db28a2f..48dc9fecf3 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,131 +1,105 @@ name: iOS IPA (SideStore Build) on: -workflow_dispatch: -push: -branches: -- dev + workflow_dispatch: + push: + branches: + - dev jobs: -build: -runs-on: macos-15 - - -steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Select Xcode 16.2 - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install dependencies - working-directory: ./mobile - run: bun install --no-frozen-lockfile - - - name: Setup environment - working-directory: ./mobile - run: cp .env.example .env - - - name: Expo prebuild (generate iOS project) - 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: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: pod install - - - name: Show available schemes (debugging) - working-directory: ./mobile/ios - run: | - echo "=== SCHEMES ===" - xcodebuild -workspace Mentra.xcworkspace -list - - - name: Build unsigned .app (Release for device) - working-directory: ./mobile/ios - env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" - run: | - set -o pipefail - - xcodebuild \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -derivedDataPath build \ - CODE_SIGNING_ALLOWED=NO \ - COPY_PHASE_STRIP=NO \ - STRIP_INSTALLED_PRODUCT=NO \ - ONLY_ACTIVE_ARCH=NO \ - 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color - - - name: Locate built .app - run: | - echo "=== Searching for .app ===" - APP_PATH=$(find mobile/ios/build/Build/Products -name "*.app" -type d | head -1) - - if [ -z "$APP_PATH" ]; then - echo "❌ ERROR: .app not found" - exit 1 - fi - - echo "✅ Found app at: $APP_PATH" - echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - - - name: Verify app bundle (important for SideStore) - run: | - echo "=== App contents ===" - ls -la "$APP_PATH" - - echo "" - echo "=== Frameworks ===" - ls "$APP_PATH/Frameworks" || echo "No Frameworks folder" - - - name: Create IPA (SideStore-compatible) - run: | - echo "=== Packaging IPA ===" - - rm -rf Payload - mkdir Payload - - cp -r "$APP_PATH" Payload/App.app - - touch Payload/App.app/embedded.mobileprovision - - zip -r Mentra.ipa Payload - - mv Mentra.ipa mobile/ios/ - - echo "✅ IPA created:" - ls -lh mobile/ios/Mentra.ipa - - - name: Upload IPA artifact - uses: actions/upload-artifact@v4 - with: - name: Mentra-SideStore-IPA - path: mobile/ios/Mentra.ipa - retention-days: 14 - - - name: Upload build log (on failure) - if: failure() - uses: actions/upload-artifact@v4 - with: - name: xcodebuild-log - path: /tmp/xcodebuild.log - retention-days: 5 + build: + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + + - 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: Install pods + working-directory: ./mobile/ios + run: pod install + + - name: Build .app (Release, unsigned) + working-directory: ./mobile/ios + env: + SENTRY_DISABLE_AUTO_UPLOAD: "true" + run: | + xcodebuild \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + COPY_PHASE_STRIP=NO \ + STRIP_INSTALLED_PRODUCT=NO \ + ONLY_ACTIVE_ARCH=NO \ + 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ + (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) + + - name: Locate .app + id: find_app + run: | + APP_PATH=$(find mobile/ios/build/Build/Products -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ .app not found" + find mobile/ios/build -maxdepth 5 -type d + exit 1 + fi + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + echo "✅ Found app at $APP_PATH" + + - name: Verify bundle structure + run: | + echo "=== App contents ===" + ls -la "${{ env.APP_PATH }}" + echo "" + echo "=== Frameworks ===" + ls "${{ env.APP_PATH }}/Frameworks" || echo "No Frameworks folder" + + - name: Package IPA (SideStore friendly) + run: | + mkdir -p Payload + cp -r "${{ env.APP_PATH }}" Payload/App.app + touch Payload/App.app/embedded.mobileprovision + zip -r Mentra.ipa Payload + mv Mentra.ipa mobile/ios/ + echo "✅ IPA packaged" + ls -lh mobile/ios/Mentra.ipa + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: Mentra-SideStore-IPA + path: mobile/ios/Mentra.ipa + retention-days: 14 + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 From e38fea5478fb4aab7b0fb622e8885fbea2619482 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:18:52 +1200 Subject: [PATCH 13/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 44 +++++++++++++++----------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 48dc9fecf3..f875d66da9 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -37,10 +37,24 @@ jobs: 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: Install pods working-directory: ./mobile/ios run: pod install + - name: Download iOS platform support files + working-directory: ./mobile/ios + run: | + echo "Downloading iOS platform support (required for device builds on hosted runners)..." + xcodebuild -downloadPlatform iOS + echo "Available destinations:" + xcodebuild -workspace Mentra.xcworkspace -scheme Mentra -showdestinations 2>&1 | head -40 || true + - name: Build .app (Release, unsigned) working-directory: ./mobile/ios env: @@ -51,33 +65,25 @@ jobs: -scheme Mentra \ -configuration Release \ -destination 'generic/platform=iOS' \ - -derivedDataPath build \ + -derivedDataPath build-device \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ - COPY_PHASE_STRIP=NO \ - STRIP_INSTALLED_PRODUCT=NO \ - ONLY_ACTIVE_ARCH=NO \ 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - name: Locate .app - id: find_app + - name: Verify .app bundle run: | - APP_PATH=$(find mobile/ios/build/Build/Products -name "*.app" -type d | head -1) - if [ -z "$APP_PATH" ]; then - echo "❌ .app not found" - find mobile/ios/build -maxdepth 5 -type d + APP_PATH="mobile/ios/build-device/Build/Products/Release-iphoneos/Mentra.app" + echo "=== App contents ===" + ls -la "$APP_PATH" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "❌ Info.plist missing — full directory tree:" + find mobile/ios/build-device/Build/Products -maxdepth 5 exit 1 fi + echo "✅ Info.plist found — bundle looks good" echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - echo "✅ Found app at $APP_PATH" - - - name: Verify bundle structure - run: | - echo "=== App contents ===" - ls -la "${{ env.APP_PATH }}" - echo "" - echo "=== Frameworks ===" - ls "${{ env.APP_PATH }}/Frameworks" || echo "No Frameworks folder" - name: Package IPA (SideStore friendly) run: | From 2141beb1d1667f1c53141d48a903e9162bd19d57 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:56:35 +1200 Subject: [PATCH 14/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 33 +++++++++++++++----------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index f875d66da9..72ae481160 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -47,42 +47,47 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Download iOS platform support files + - name: Download iOS platform support working-directory: ./mobile/ios - run: | - echo "Downloading iOS platform support (required for device builds on hosted runners)..." - xcodebuild -downloadPlatform iOS - echo "Available destinations:" - xcodebuild -workspace Mentra.xcworkspace -scheme Mentra -showdestinations 2>&1 | head -40 || true + run: xcodebuild -downloadPlatform iOS - - name: Build .app (Release, unsigned) + - name: Archive app (forces full bundle assembly) working-directory: ./mobile/ios env: SENTRY_DISABLE_AUTO_UPLOAD: "true" run: | - xcodebuild \ + xcodebuild archive \ -workspace Mentra.xcworkspace \ -scheme Mentra \ -configuration Release \ -destination 'generic/platform=iOS' \ - -derivedDataPath build-device \ + -archivePath build-device/Mentra.xcarchive \ 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 | xcpretty --color || \ (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - name: Verify .app bundle + - name: Verify archive and locate .app run: | - APP_PATH="mobile/ios/build-device/Build/Products/Release-iphoneos/Mentra.app" + echo "=== Archive contents ===" + find mobile/ios/build-device/Mentra.xcarchive -name "*.app" -type d + APP_PATH=$(find mobile/ios/build-device/Mentra.xcarchive -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ .app not found in archive" + find mobile/ios/build-device/Mentra.xcarchive -maxdepth 5 + exit 1 + fi + echo "✅ Found: $APP_PATH" echo "=== App contents ===" ls -la "$APP_PATH" if [ ! -f "$APP_PATH/Info.plist" ]; then - echo "❌ Info.plist missing — full directory tree:" - find mobile/ios/build-device/Build/Products -maxdepth 5 + echo "❌ Info.plist still missing" exit 1 fi - echo "✅ Info.plist found — bundle looks good" + echo "✅ Info.plist present" echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - name: Package IPA (SideStore friendly) From 543c7d43bc564a44917c8304c28530d3936073e0 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:15:22 +1200 Subject: [PATCH 15/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 230 +++++++++++++------------ 1 file changed, 119 insertions(+), 111 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 72ae481160..14652af937 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,116 +1,124 @@ name: iOS IPA (SideStore Build) on: - workflow_dispatch: - push: - branches: - - dev +workflow_dispatch: +push: +branches: +- dev jobs: - build: - runs-on: macos-15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - - - 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: Install pods - working-directory: ./mobile/ios - run: pod install - - - name: Download iOS platform support - working-directory: ./mobile/ios - run: xcodebuild -downloadPlatform iOS - - - name: Archive app (forces full bundle assembly) - working-directory: ./mobile/ios - env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" - run: | - xcodebuild archive \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath build-device/Mentra.xcarchive \ - 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 | xcpretty --color || \ - (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - - name: Verify archive and locate .app - run: | - echo "=== Archive contents ===" - find mobile/ios/build-device/Mentra.xcarchive -name "*.app" -type d - APP_PATH=$(find mobile/ios/build-device/Mentra.xcarchive -name "*.app" -type d | head -1) - if [ -z "$APP_PATH" ]; then - echo "❌ .app not found in archive" - find mobile/ios/build-device/Mentra.xcarchive -maxdepth 5 - exit 1 - fi - echo "✅ Found: $APP_PATH" - echo "=== App contents ===" - ls -la "$APP_PATH" - if [ ! -f "$APP_PATH/Info.plist" ]; then - echo "❌ Info.plist still missing" - exit 1 - fi - echo "✅ Info.plist present" - echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - - - name: Package IPA (SideStore friendly) - run: | - mkdir -p Payload - cp -r "${{ env.APP_PATH }}" Payload/App.app - touch Payload/App.app/embedded.mobileprovision - zip -r Mentra.ipa Payload - mv Mentra.ipa mobile/ios/ - echo "✅ IPA packaged" - ls -lh mobile/ios/Mentra.ipa - - - name: Upload IPA - uses: actions/upload-artifact@v4 - with: - name: Mentra-SideStore-IPA - path: mobile/ios/Mentra.ipa - retention-days: 14 - - - name: Upload logs on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: xcodebuild-log - path: /tmp/xcodebuild.log - retention-days: 5 +build: +runs-on: macos-15 + +steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Select Xcode 16.2 + run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - 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: Install pods + working-directory: ./mobile/ios + run: pod install + - name: Download iOS platform support + working-directory: ./mobile/ios + run: xcodebuild -downloadPlatform iOS + - name: Archive app (unsigned, SideStore-friendly) + 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 build-device/Mentra.xcarchive \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + COPY_PHASE_STRIP=NO \ + STRIP_INSTALLED_PRODUCT=NO \ + ONLY_ACTIVE_ARCH=NO \ + 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ + (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) + - name: Locate .app inside archive + run: | + echo "=== Searching archive for .app ===" + APP_PATH=$(find mobile/ios/build-device/Mentra.xcarchive -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ ERROR: .app not found" + find mobile/ios/build-device/Mentra.xcarchive -maxdepth 5 + exit 1 + fi + echo "✅ Found app at: $APP_PATH" + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + - name: Verify app bundle + run: | + echo "=== App contents ===" + ls -la "$APP_PATH" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "❌ Missing Info.plist" + exit 1 + fi + echo "✅ Info.plist present" + echo "" + echo "=== Frameworks ===" + ls "$APP_PATH/Frameworks" || echo "No Frameworks folder" + - name: Strip simulator architectures (optional but recommended) + run: | + echo "=== Stripping simulator architectures ===" + find "$APP_PATH" -name "*.framework" -type d | while read framework; do + binary=$(find "$framework" -type f -perm +111 | head -1) + if [ -f "$binary" ]; then + lipo -remove x86_64 -remove arm64e "$binary" -o "$binary" 2>/dev/null || true + fi + done + - name: Package IPA (SideStore compatible) + run: | + echo "=== Packaging IPA ===" + rm -rf Payload + mkdir Payload + APP_NAME=$(basename "$APP_PATH") + cp -r "$APP_PATH" "Payload/$APP_NAME" + # Optional placeholder (helps some sideload tools) + touch "Payload/$APP_NAME/embedded.mobileprovision" + echo "=== Payload contents ===" + ls -la Payload + zip -r Mentra.ipa Payload + mv Mentra.ipa mobile/ios/ + echo "✅ IPA created:" + ls -lh mobile/ios/Mentra.ipa + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: Mentra-SideStore-IPA + path: mobile/ios/Mentra.ipa + retention-days: 14 + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 \ No newline at end of file From 84d020ffe3e8c36fdd098fd20491bbc25efeb2cc Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:21:43 +1200 Subject: [PATCH 16/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 56 ++++++-------------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 14652af937..5b02100f10 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -13,12 +13,10 @@ runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 16.2 + - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - name: Setup Bun uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - name: Setup Node uses: actions/setup-node@v4 with: @@ -32,93 +30,63 @@ steps: - 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: Install pods working-directory: ./mobile/ios run: pod install - - name: Download iOS platform support - working-directory: ./mobile/ios - run: xcodebuild -downloadPlatform iOS - - name: Archive app (unsigned, SideStore-friendly) + - name: Build .app (Release) working-directory: ./mobile/ios env: SENTRY_DISABLE_AUTO_UPLOAD: "true" run: | set -o pipefail - xcodebuild archive \ + xcodebuild \ -workspace Mentra.xcworkspace \ -scheme Mentra \ -configuration Release \ -destination 'generic/platform=iOS' \ - -archivePath build-device/Mentra.xcarchive \ + -derivedDataPath build \ CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ COPY_PHASE_STRIP=NO \ STRIP_INSTALLED_PRODUCT=NO \ ONLY_ACTIVE_ARCH=NO \ 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - name: Locate .app inside archive + - name: Locate .app run: | - echo "=== Searching archive for .app ===" - APP_PATH=$(find mobile/ios/build-device/Mentra.xcarchive -name "*.app" -type d | head -1) + APP_PATH=$(find mobile/ios/build/Build/Products -name "*.app" -type d | head -1) if [ -z "$APP_PATH" ]; then - echo "❌ ERROR: .app not found" - find mobile/ios/build-device/Mentra.xcarchive -maxdepth 5 + echo "❌ .app not found" exit 1 fi - echo "✅ Found app at: $APP_PATH" echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - - name: Verify app bundle - run: | - echo "=== App contents ===" - ls -la "$APP_PATH" - if [ ! -f "$APP_PATH/Info.plist" ]; then - echo "❌ Missing Info.plist" - exit 1 - fi - echo "✅ Info.plist present" - echo "" - echo "=== Frameworks ===" - ls "$APP_PATH/Frameworks" || echo "No Frameworks folder" - - name: Strip simulator architectures (optional but recommended) + echo "✅ Found: $APP_PATH" + - name: Strip simulator architectures (recommended) run: | - echo "=== Stripping simulator architectures ===" find "$APP_PATH" -name "*.framework" -type d | while read framework; do binary=$(find "$framework" -type f -perm +111 | head -1) if [ -f "$binary" ]; then lipo -remove x86_64 -remove arm64e "$binary" -o "$binary" 2>/dev/null || true fi done - - name: Package IPA (SideStore compatible) + - name: Package IPA run: | - echo "=== Packaging IPA ===" rm -rf Payload mkdir Payload APP_NAME=$(basename "$APP_PATH") cp -r "$APP_PATH" "Payload/$APP_NAME" - # Optional placeholder (helps some sideload tools) touch "Payload/$APP_NAME/embedded.mobileprovision" - echo "=== Payload contents ===" - ls -la Payload zip -r Mentra.ipa Payload mv Mentra.ipa mobile/ios/ - echo "✅ IPA created:" + echo "✅ IPA created" ls -lh mobile/ios/Mentra.ipa - name: Upload IPA uses: actions/upload-artifact@v4 with: name: Mentra-SideStore-IPA path: mobile/ios/Mentra.ipa - retention-days: 14 - name: Upload logs on failure if: failure() uses: actions/upload-artifact@v4 with: name: xcodebuild-log - path: /tmp/xcodebuild.log - retention-days: 5 \ No newline at end of file + path: /tmp/xcodebuild.log \ No newline at end of file From ae979dbb27779e3b108879eb407c09bc6f40fad9 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:54:42 +1200 Subject: [PATCH 17/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 206 ++++++++++++++----------- 1 file changed, 119 insertions(+), 87 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 5b02100f10..d8c7570b0f 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,92 +1,124 @@ name: iOS IPA (SideStore Build) on: -workflow_dispatch: -push: -branches: -- dev + workflow_dispatch: + push: + branches: + - dev jobs: -build: -runs-on: macos-15 - -steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - - 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: Install pods - working-directory: ./mobile/ios - run: pod install - - name: Build .app (Release) - working-directory: ./mobile/ios - env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" - run: | - set -o pipefail - xcodebuild \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -derivedDataPath build \ - CODE_SIGNING_ALLOWED=NO \ - COPY_PHASE_STRIP=NO \ - STRIP_INSTALLED_PRODUCT=NO \ - ONLY_ACTIVE_ARCH=NO \ - 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color || \ - (echo "=== ERRORS ===" && grep "error:" /tmp/xcodebuild.log | tail -80 && exit 1) - - name: Locate .app - run: | - APP_PATH=$(find mobile/ios/build/Build/Products -name "*.app" -type d | head -1) - if [ -z "$APP_PATH" ]; then - echo "❌ .app not found" - exit 1 - fi - echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - echo "✅ Found: $APP_PATH" - - name: Strip simulator architectures (recommended) - run: | - find "$APP_PATH" -name "*.framework" -type d | while read framework; do - binary=$(find "$framework" -type f -perm +111 | head -1) - if [ -f "$binary" ]; then - lipo -remove x86_64 -remove arm64e "$binary" -o "$binary" 2>/dev/null || true - fi - done - - name: Package IPA - run: | - rm -rf Payload - mkdir Payload - APP_NAME=$(basename "$APP_PATH") - cp -r "$APP_PATH" "Payload/$APP_NAME" - touch "Payload/$APP_NAME/embedded.mobileprovision" - zip -r Mentra.ipa Payload - mv Mentra.ipa mobile/ios/ - echo "✅ IPA created" - ls -lh mobile/ios/Mentra.ipa - - name: Upload IPA - uses: actions/upload-artifact@v4 - with: - name: Mentra-SideStore-IPA - path: mobile/ios/Mentra.ipa - - name: Upload logs on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: xcodebuild-log - path: /tmp/xcodebuild.log \ No newline at end of file + build: + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + + - 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: Install pods + working-directory: ./mobile/ios + run: pod install + + - name: Download iOS platform support + run: xcodebuild -downloadPlatform iOS + + - 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" \ + 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 + echo "Exit code: $?" + + - name: Show last 100 lines of build log + if: always() + run: tail -100 /tmp/xcodebuild.log + + - name: Verify archive + run: | + echo "=== Looking for xcarchive ===" + find "$GITHUB_WORKSPACE" -name "Mentra.xcarchive" -maxdepth 3 2>/dev/null || echo "xcarchive not found" + echo "=== Looking for any .app ===" + find "$GITHUB_WORKSPACE" -name "*.app" -type d -maxdepth 10 2>/dev/null | head -10 + + APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ Mentra.app not found anywhere" + exit 1 + fi + echo "✅ Found: $APP_PATH" + ls -la "$APP_PATH" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "❌ Info.plist missing" + exit 1 + fi + echo "✅ Info.plist present" + 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 + echo "✅ IPA packaged" + 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 full build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 From 4fae88b56049403ed965a5e52486a01ff5e48d6b Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:05:42 +1200 Subject: [PATCH 18/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index d8c7570b0f..93a24c7c14 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -47,9 +47,6 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Download iOS platform support - run: xcodebuild -downloadPlatform iOS - - name: List schemes working-directory: ./mobile/ios run: xcodebuild -workspace Mentra.xcworkspace -list @@ -72,22 +69,20 @@ jobs: AD_HOC_CODE_SIGNING_ALLOWED=YES \ DEVELOPMENT_TEAM="" \ 2>&1 | tee /tmp/xcodebuild.log - echo "Exit code: $?" + echo "xcodebuild exit: $?" - name: Show last 100 lines of build log if: always() - run: tail -100 /tmp/xcodebuild.log + run: tail -100 /tmp/xcodebuild.log || echo "No log file found" - - name: Verify archive + - name: Verify archive and find .app run: | - echo "=== Looking for xcarchive ===" - find "$GITHUB_WORKSPACE" -name "Mentra.xcarchive" -maxdepth 3 2>/dev/null || echo "xcarchive not found" - echo "=== Looking for any .app ===" - find "$GITHUB_WORKSPACE" -name "*.app" -type d -maxdepth 10 2>/dev/null | head -10 + echo "=== Looking for Mentra.app ===" + find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d 2>/dev/null | head -5 APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) if [ -z "$APP_PATH" ]; then - echo "❌ Mentra.app not found anywhere" + echo "❌ Mentra.app not found" exit 1 fi echo "✅ Found: $APP_PATH" @@ -115,7 +110,7 @@ jobs: path: Mentra.ipa retention-days: 14 - - name: Upload full build log + - name: Upload build log if: always() uses: actions/upload-artifact@v4 with: From 047aef34ea3b0f1f07bb6d4fabe1a1d742e96ce5 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:10:06 +1200 Subject: [PATCH 19/73] Update iOS build workflow to use Xcode 15.4 --- .github/workflows/ios-unsigned-ipa.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 93a24c7c14..a291ed0a41 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -8,14 +8,17 @@ on: jobs: build: - runs-on: macos-15 + runs-on: macos-14 steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + - name: Select Xcode 15.4 + run: | + sudo xcode-select -s /Applications/Xcode_15.4.app/Contents/Developer + xcodebuild -version + swift --version - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -63,26 +66,26 @@ jobs: -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ + SWIFT_VERSION=5 \ + SWIFT_STRICT_CONCURRENCY=minimal \ 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 - echo "xcodebuild exit: $?" + echo "Exit: $?" - name: Show last 100 lines of build log if: always() - run: tail -100 /tmp/xcodebuild.log || echo "No log file found" + run: tail -100 /tmp/xcodebuild.log || echo "No log file" - name: Verify archive and find .app run: | - echo "=== Looking for Mentra.app ===" - find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d 2>/dev/null | head -5 - - APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) + APP_PATH=$(find "$GITHUB_WORKSPACE/Mentra.xcarchive" -name "Mentra.app" -type d | head -1) if [ -z "$APP_PATH" ]; then - echo "❌ Mentra.app not found" + echo "❌ Mentra.app not found in archive" + find "$GITHUB_WORKSPACE/Mentra.xcarchive" -maxdepth 6 2>/dev/null || echo "No archive found" exit 1 fi echo "✅ Found: $APP_PATH" From 04e0c0862ffde1551f878335df2929b669eaa474 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:18:37 +1200 Subject: [PATCH 20/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 275 ++++++++++++++----------- 1 file changed, 157 insertions(+), 118 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index a291ed0a41..d79b5e1798 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,122 +1,161 @@ -name: iOS IPA (SideStore Build) +name: iOS IPA (Expo / React Native - Stable CI) on: - workflow_dispatch: - push: - branches: - - dev +workflow_dispatch: +push: +branches: [dev] jobs: - build: - runs-on: macos-14 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select Xcode 15.4 - run: | - sudo xcode-select -s /Applications/Xcode_15.4.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: 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" \ - SWIFT_VERSION=5 \ - SWIFT_STRICT_CONCURRENCY=minimal \ - 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 - echo "Exit: $?" - - - name: Show last 100 lines of build log - if: always() - run: tail -100 /tmp/xcodebuild.log || echo "No log file" - - - name: Verify archive and find .app - run: | - APP_PATH=$(find "$GITHUB_WORKSPACE/Mentra.xcarchive" -name "Mentra.app" -type d | head -1) - if [ -z "$APP_PATH" ]; then - echo "❌ Mentra.app not found in archive" - find "$GITHUB_WORKSPACE/Mentra.xcarchive" -maxdepth 6 2>/dev/null || echo "No archive found" - exit 1 - fi - echo "✅ Found: $APP_PATH" - ls -la "$APP_PATH" - if [ ! -f "$APP_PATH/Info.plist" ]; then - echo "❌ Info.plist missing" - exit 1 - fi - echo "✅ Info.plist present" - 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 - echo "✅ IPA packaged" - 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 build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: xcodebuild-log - path: /tmp/xcodebuild.log - retention-days: 5 +build: +runs-on: macos-15 + +env: + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + NODE_OPTIONS: --max-old-space-size=4096 + CI: true +steps: + # ------------------------- + # Checkout + # ------------------------- + - name: Checkout + uses: actions/checkout@v4 + # ------------------------- + # Ensure correct Xcode + # ------------------------- + - name: Setup Xcode 16.2 + run: | + sudo xcode-select -s $DEVELOPER_DIR + xcodebuild -version + xcode-select -p + - name: Accept Xcode license & init + run: | + sudo xcodebuild -runFirstLaunch + sudo xcodebuild -license accept || true + # ------------------------- + # Node + Bun + # ------------------------- + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + # ------------------------- + # Cache dependencies + # ------------------------- + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: mobile/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('mobile/bun.lockb', 'mobile/package.json') }} + - name: Cache Pods + uses: actions/cache@v4 + with: + path: | + mobile/ios/Pods + mobile/ios/Podfile.lock + key: ${{ runner.os }}-pods-${{ hashFiles('mobile/ios/Podfile.lock') }} + # ------------------------- + # Install deps + # ------------------------- + - name: Install dependencies + working-directory: ./mobile + run: bun install --no-frozen-lockfile + # ------------------------- + # Env setup + # ------------------------- + - name: Setup env + working-directory: ./mobile + run: cp .env.example .env + # ------------------------- + # Expo prebuild (clean!) + # ------------------------- + - name: Expo prebuild (clean) + working-directory: ./mobile + run: | + bunx expo prebuild --platform ios --clean + # ------------------------- + # Fix common RN/Xcode issues + # ------------------------- + - name: Patch iOS build settings + working-directory: ./mobile + run: | + # Disable new architecture if causing CI issues + sed -i '' 's/USE_NEW_ARCHITECTURE=1/USE_NEW_ARCHITECTURE=0/g' ios/Podfile || true + # ------------------------- + # Install Pods (forced correct Xcode) + # ------------------------- + - name: Install CocoaPods + working-directory: ./mobile/ios + run: | + pod deintegrate + pod install --repo-update + # ------------------------- + # Verify schemes + # ------------------------- + - name: List schemes + working-directory: ./mobile/ios + run: xcodebuild -workspace *.xcworkspace -list + # ------------------------- + # Build archive + # ------------------------- + - name: Archive app + working-directory: ./mobile/ios + env: + SENTRY_DISABLE_AUTO_UPLOAD: "true" + run: | + set -o pipefail + xcodebuild archive \ + -workspace *.xcworkspace \ + -scheme $(xcodebuild -list -json | jq -r '.workspace.schemes[0]') \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -sdk iphoneos \ + -archivePath $GITHUB_WORKSPACE/app.xcarchive \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM="" \ + 2>&1 | tee /tmp/xcodebuild.log + # ------------------------- + # Debug logs on failure + # ------------------------- + - name: Show build log + if: always() + run: tail -200 /tmp/xcodebuild.log || true + # ------------------------- + # Extract .app + # ------------------------- + - name: Extract app + run: | + APP_PATH=$(find $GITHUB_WORKSPACE/app.xcarchive -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ .app not found" + exit 1 + fi + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + ls -la $APP_PATH + # ------------------------- + # Package IPA + # ------------------------- + - name: Create IPA + run: | + mkdir Payload + cp -r "$APP_PATH" Payload/App.app + touch Payload/App.app/embedded.mobileprovision + zip -r app.ipa Payload + # ------------------------- + # Upload artifacts + # ------------------------- + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: app.ipa + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-logs + path: /tmp/xcodebuild.log \ No newline at end of file From cdf5ecad6f8c79d3235b8101c6602b56cb54cdc6 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:24:17 +1200 Subject: [PATCH 21/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 265 ++++++++++--------------- 1 file changed, 110 insertions(+), 155 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index d79b5e1798..b7f55a0d47 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,161 +1,116 @@ name: iOS IPA (Expo / React Native - Stable CI) on: -workflow_dispatch: -push: -branches: [dev] + workflow_dispatch: + push: + branches: + - dev jobs: -build: -runs-on: macos-15 - -env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer - NODE_OPTIONS: --max-old-space-size=4096 - CI: true -steps: - # ------------------------- - # Checkout - # ------------------------- - - name: Checkout - uses: actions/checkout@v4 - # ------------------------- - # Ensure correct Xcode - # ------------------------- - - name: Setup Xcode 16.2 - run: | - sudo xcode-select -s $DEVELOPER_DIR - xcodebuild -version - xcode-select -p - - name: Accept Xcode license & init - run: | - sudo xcodebuild -runFirstLaunch - sudo xcodebuild -license accept || true - # ------------------------- - # Node + Bun - # ------------------------- - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - # ------------------------- - # Cache dependencies - # ------------------------- - - name: Cache node_modules - uses: actions/cache@v4 - with: - path: mobile/node_modules - key: ${{ runner.os }}-node-${{ hashFiles('mobile/bun.lockb', 'mobile/package.json') }} - - name: Cache Pods - uses: actions/cache@v4 - with: - path: | - mobile/ios/Pods - mobile/ios/Podfile.lock - key: ${{ runner.os }}-pods-${{ hashFiles('mobile/ios/Podfile.lock') }} - # ------------------------- - # Install deps - # ------------------------- - - name: Install dependencies - working-directory: ./mobile - run: bun install --no-frozen-lockfile - # ------------------------- - # Env setup - # ------------------------- - - name: Setup env - working-directory: ./mobile - run: cp .env.example .env - # ------------------------- - # Expo prebuild (clean!) - # ------------------------- - - name: Expo prebuild (clean) - working-directory: ./mobile - run: | - bunx expo prebuild --platform ios --clean - # ------------------------- - # Fix common RN/Xcode issues - # ------------------------- - - name: Patch iOS build settings - working-directory: ./mobile - run: | - # Disable new architecture if causing CI issues - sed -i '' 's/USE_NEW_ARCHITECTURE=1/USE_NEW_ARCHITECTURE=0/g' ios/Podfile || true - # ------------------------- - # Install Pods (forced correct Xcode) - # ------------------------- - - name: Install CocoaPods - working-directory: ./mobile/ios - run: | - pod deintegrate - pod install --repo-update - # ------------------------- - # Verify schemes - # ------------------------- - - name: List schemes - working-directory: ./mobile/ios - run: xcodebuild -workspace *.xcworkspace -list - # ------------------------- - # Build archive - # ------------------------- - - name: Archive app - working-directory: ./mobile/ios + build: + runs-on: macos-15 + env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" - run: | - set -o pipefail - xcodebuild archive \ - -workspace *.xcworkspace \ - -scheme $(xcodebuild -list -json | jq -r '.workspace.schemes[0]') \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -sdk iphoneos \ - -archivePath $GITHUB_WORKSPACE/app.xcarchive \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - DEVELOPMENT_TEAM="" \ - 2>&1 | tee /tmp/xcodebuild.log - # ------------------------- - # Debug logs on failure - # ------------------------- - - name: Show build log - if: always() - run: tail -200 /tmp/xcodebuild.log || true - # ------------------------- - # Extract .app - # ------------------------- - - name: Extract app - run: | - APP_PATH=$(find $GITHUB_WORKSPACE/app.xcarchive -name "*.app" -type d | head -1) - if [ -z "$APP_PATH" ]; then - echo "❌ .app not found" - exit 1 - fi - echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - ls -la $APP_PATH - # ------------------------- - # Package IPA - # ------------------------- - - name: Create IPA - run: | - mkdir Payload - cp -r "$APP_PATH" Payload/App.app - touch Payload/App.app/embedded.mobileprovision - zip -r app.ipa Payload - # ------------------------- - # Upload artifacts - # ------------------------- - - name: Upload IPA - uses: actions/upload-artifact@v4 - with: - name: ios-ipa - path: app.ipa - - name: Upload logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: build-logs - path: /tmp/xcodebuild.log \ No newline at end of file + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + NODE_OPTIONS: --max-old-space-size=4096 + CI: true + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Xcode 16.2 + run: | + sudo xcode-select -s $DEVELOPER_DIR + xcodebuild -version + xcode-select -p + + - name: Accept Xcode license + run: | + sudo xcodebuild -runFirstLaunch + sudo xcodebuild -license accept || true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + 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: bunx expo prebuild --platform ios --clean + + - name: Install CocoaPods + working-directory: ./mobile/ios + run: | + pod deintegrate + pod install --repo-update + + - name: List schemes + working-directory: ./mobile/ios + run: xcodebuild -workspace *.xcworkspace -list + + - name: Archive app + working-directory: ./mobile/ios + env: + SENTRY_DISABLE_AUTO_UPLOAD: "true" + run: | + set -o pipefail + xcodebuild archive \ + -workspace *.xcworkspace \ + -scheme $(xcodebuild -list -json | /usr/bin/python3 -c "import sys, json; print(json.load(sys.stdin)['workspace']['schemes'][0])") \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -sdk iphoneos \ + -archivePath $GITHUB_WORKSPACE/app.xcarchive \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM="" \ + 2>&1 | tee /tmp/xcodebuild.log + + - name: Show build log + if: always() + run: tail -200 /tmp/xcodebuild.log || true + + - name: Extract app + run: | + APP_PATH=$(find $GITHUB_WORKSPACE/app.xcarchive -name "*.app" -type d | head -1) + + if [ -z "$APP_PATH" ]; then + echo "❌ .app not found" + exit 1 + fi + + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + ls -la $APP_PATH + + - name: Create IPA + run: | + mkdir Payload + cp -r "$APP_PATH" Payload/App.app + touch Payload/App.app/embedded.mobileprovision + zip -r app.ipa Payload + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: app.ipa + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-logs + path: /tmp/xcodebuild.log From 46fa7edb5ac7a7c665b1d04130a3d20d8c42df60 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:23:53 +1200 Subject: [PATCH 22/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index b7f55a0d47..98f50aa527 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -63,21 +63,22 @@ jobs: - name: Archive app working-directory: ./mobile/ios env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" + SENTRY_DISABLE_AUTO_UPLOAD: "true" run: | - set -o pipefail - xcodebuild archive \ - -workspace *.xcworkspace \ - -scheme $(xcodebuild -list -json | /usr/bin/python3 -c "import sys, json; print(json.load(sys.stdin)['workspace']['schemes'][0])") \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -sdk iphoneos \ - -archivePath $GITHUB_WORKSPACE/app.xcarchive \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - DEVELOPMENT_TEAM="" \ - 2>&1 | tee /tmp/xcodebuild.log + set -o pipefail + + xcodebuild archive \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -sdk iphoneos \ + -archivePath $GITHUB_WORKSPACE/app.xcarchive \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM="" \ + 2>&1 | tee /tmp/xcodebuild.log - name: Show build log if: always() From 0bd744b3b8801f33009afa342b50b2e2690b53f4 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:21:33 +1200 Subject: [PATCH 23/73] Update iOS IPA workflow for SideStore build --- .github/workflows/ios-unsigned-ipa.yml | 116 +++++++++++++------------ 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 98f50aa527..9b71d11a87 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,4 +1,4 @@ -name: iOS IPA (Expo / React Native - Stable CI) +name: iOS IPA (SideStore Build) on: workflow_dispatch: @@ -8,37 +8,27 @@ on: jobs: build: - runs-on: macos-15 - - env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer - NODE_OPTIONS: --max-old-space-size=4096 - CI: true + runs-on: macos-14 steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Xcode 16.2 + - name: Select Xcode 16.1 run: | - sudo xcode-select -s $DEVELOPER_DIR + sudo xcode-select -s /Applications/Xcode_16.1.app/Contents/Developer xcodebuild -version - xcode-select -p + xcodebuild -showsdks | grep iphoneos - - name: Accept Xcode license - run: | - sudo xcodebuild -runFirstLaunch - sudo xcodebuild -license accept || true + - name: Setup Bun + uses: oven-sh/setup-bun@v2 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies + - name: Install deps working-directory: ./mobile run: bun install --no-frozen-lockfile @@ -48,70 +38,84 @@ jobs: - name: Expo prebuild working-directory: ./mobile - run: bunx expo prebuild --platform ios --clean + run: bun expo prebuild --platform ios - - name: Install CocoaPods - working-directory: ./mobile/ios + - name: Setup Sherpa Onnx (optional) + working-directory: ./mobile run: | - pod deintegrate - pod install --repo-update + chmod +x scripts/setup-sherpa-onnx-optional.sh + ./scripts/setup-sherpa-onnx-optional.sh || true + + - name: Install pods + working-directory: ./mobile/ios + run: pod install - name: List schemes working-directory: ./mobile/ios - run: xcodebuild -workspace *.xcworkspace -list + run: xcodebuild -workspace Mentra.xcworkspace -list - name: Archive app working-directory: ./mobile/ios env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" + SENTRY_DISABLE_AUTO_UPLOAD: "true" run: | - set -o pipefail - - xcodebuild archive \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -sdk iphoneos \ - -archivePath $GITHUB_WORKSPACE/app.xcarchive \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - DEVELOPMENT_TEAM="" \ - 2>&1 | tee /tmp/xcodebuild.log - - - name: Show build log + set -o pipefail + xcodebuild archive \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -sdk iphoneos \ + -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ + 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 + echo "xcodebuild exit: $?" + + - name: Show last 100 lines of build log if: always() - run: tail -200 /tmp/xcodebuild.log || true + run: tail -100 /tmp/xcodebuild.log || echo "No log file" - - name: Extract app + - name: Verify archive and find .app run: | - APP_PATH=$(find $GITHUB_WORKSPACE/app.xcarchive -name "*.app" -type d | head -1) - + APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) if [ -z "$APP_PATH" ]; then - echo "❌ .app not found" + echo "❌ Mentra.app not found" + find "$GITHUB_WORKSPACE/Mentra.xcarchive" -maxdepth 5 2>/dev/null || echo "No xcarchive found" exit 1 fi - + echo "✅ Found: $APP_PATH" + ls -la "$APP_PATH" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "❌ Info.plist missing" + exit 1 + fi + echo "✅ Info.plist present" echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - ls -la $APP_PATH - - name: Create IPA + - name: Package IPA run: | - mkdir Payload - cp -r "$APP_PATH" Payload/App.app + mkdir -p Payload + cp -r "${{ env.APP_PATH }}" Payload/App.app touch Payload/App.app/embedded.mobileprovision - zip -r app.ipa Payload + zip -r Mentra.ipa Payload + echo "✅ IPA packaged" + ls -lh Mentra.ipa - name: Upload IPA uses: actions/upload-artifact@v4 with: - name: ios-ipa - path: app.ipa + name: Mentra-SideStore-IPA + path: Mentra.ipa + retention-days: 14 - - name: Upload logs + - name: Upload build log if: always() uses: actions/upload-artifact@v4 with: - name: build-logs + name: xcodebuild-log path: /tmp/xcodebuild.log + retention-days: 5 From 328ded4d7bc49dd10ad3d91fde6605e1b0da3a2b Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:52:20 +1200 Subject: [PATCH 24/73] Update iOS build workflow for new configurations --- .github/workflows/ios-unsigned-ipa.yml | 168 ++++++++++++------------- 1 file changed, 82 insertions(+), 86 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 9b71d11a87..6816bf0edb 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,121 +1,117 @@ -name: iOS IPA (SideStore Build) +name: iOS Build sidestore on: - workflow_dispatch: push: - branches: - - dev + branches: [main, develop] + pull_request: + branches: [main] + workflow_dispatch: + +env: + WORKING_DIR: mobile + IOS_DIR: mobile/ios jobs: - build: - runs-on: macos-14 + build-ios: + name: iOS Archive + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 16.1 + - name: Select Xcode 16.4 run: | - sudo xcode-select -s /Applications/Xcode_16.1.app/Contents/Developer + sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer xcodebuild -version - xcodebuild -showsdks | grep iphoneos + swift --version - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Setup Node + - name: Setup Node 20 uses: actions/setup-node@v4 with: - node-version: "20" + node-version: '20' + cache: 'npm' + cache-dependency-path: ${{ env.WORKING_DIR }}/package-lock.json - - name: Install deps - working-directory: ./mobile - run: bun install --no-frozen-lockfile + - name: Install Node dependencies + working-directory: ${{ env.WORKING_DIR }} + run: npm ci - - name: Setup env - working-directory: ./mobile - run: cp .env.example .env + - name: Setup Ruby 3.3 + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' - - name: Expo prebuild - working-directory: ./mobile - run: bun expo prebuild --platform ios + - name: Install CocoaPods gem + run: gem install cocoapods --no-document - - name: Setup Sherpa Onnx (optional) - working-directory: ./mobile + - name: Cache CocoaPods + uses: actions/cache@v4 + id: pods-cache + with: + path: ${{ env.IOS_DIR }}/Pods + key: ${{ runner.os }}-pods-${{ hashFiles(format('{0}/Podfile.lock', env.IOS_DIR)) }} + restore-keys: | + ${{ runner.os }}-pods- + + - name: Pod install + if: steps.pods-cache.outputs.cache-hit != 'true' + working-directory: ${{ env.IOS_DIR }} + run: pod install --repo-update + + - name: Fix Pod deployment targets + working-directory: ${{ env.IOS_DIR }} run: | - chmod +x scripts/setup-sherpa-onnx-optional.sh - ./scripts/setup-sherpa-onnx-optional.sh || true - - - 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" + find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do + sed -i '' \ + -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ + -e 's/IPHONEOS_DEPLOYMENT_TARGET = 10\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ + -e 's/IPHONEOS_DEPLOYMENT_TARGET = 11\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ + "$f" + done + + - name: Build archive + working-directory: ${{ env.IOS_DIR }} run: | set -o pipefail + xcodebuild archive \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ + -workspace MentraOSGO2.xcworkspace \ + -scheme MentraOSGO2 \ -configuration Release \ - -destination 'generic/platform=iOS' \ - -sdk iphoneos \ - -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ + -archivePath "$RUNNER_TEMP/MentraOSGO2.xcarchive" \ + -destination "generic/platform=iOS" \ + IPHONEOS_DEPLOYMENT_TARGET=13.0 \ + SWIFT_COMPILATION_MODE=singlefile \ CODE_SIGNING_ALLOWED=NO \ - AD_HOC_CODE_SIGNING_ALLOWED=YES \ - DEVELOPMENT_TEAM="" \ - 2>&1 | tee /tmp/xcodebuild.log - echo "xcodebuild exit: $?" + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + PROVISIONING_PROFILE_SPECIFIER="" \ + 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color --report html --output /tmp/build-report.html - - name: Show last 100 lines of build log - if: always() - run: tail -100 /tmp/xcodebuild.log || echo "No log file" + BUILD_RESULT=${PIPESTATUS[0]} - - 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" - find "$GITHUB_WORKSPACE/Mentra.xcarchive" -maxdepth 5 2>/dev/null || echo "No xcarchive found" - exit 1 - fi - echo "✅ Found: $APP_PATH" - ls -la "$APP_PATH" - if [ ! -f "$APP_PATH/Info.plist" ]; then - echo "❌ Info.plist missing" - exit 1 + if [ "$BUILD_RESULT" -ne 0 ]; then + echo "" + echo "========== BUILD FAILED — LAST 200 LINES ==========" + tail -200 /tmp/xcodebuild.log + exit "$BUILD_RESULT" fi - echo "✅ Info.plist present" - 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 - echo "✅ IPA packaged" - ls -lh Mentra.ipa - - - name: Upload IPA + - name: Upload full build log (always) + if: always() uses: actions/upload-artifact@v4 with: - name: Mentra-SideStore-IPA - path: Mentra.ipa + name: xcodebuild-log + path: | + /tmp/xcodebuild.log + /tmp/build-report.html retention-days: 14 - - name: Upload build log - if: always() + - name: Upload archive + if: success() uses: actions/upload-artifact@v4 with: - name: xcodebuild-log - path: /tmp/xcodebuild.log - retention-days: 5 + name: MentraOSGO2-archive + path: ${{ runner.temp }}/MentraOSGO2.xcarchive + retention-days: 30 From d5dda67781c738bfb06e17c3f847992804da6663 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:56:49 +1200 Subject: [PATCH 25/73] Refactor iOS build workflow and dependency setup Updated the iOS build workflow to use Bun for dependency management and adjusted various steps for clarity and efficiency. --- .github/workflows/ios-unsigned-ipa.yml | 146 +++++++++++++++---------- 1 file changed, 87 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 6816bf0edb..93878973f4 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -1,19 +1,13 @@ -name: iOS Build sidestore +name: iOS IPA (SideStore Build) on: - push: - branches: [main, develop] - pull_request: - branches: [main] workflow_dispatch: - -env: - WORKING_DIR: mobile - IOS_DIR: mobile/ios + push: + branches: + - dev jobs: - build-ios: - name: iOS Archive + build: runs-on: macos-15 steps: @@ -24,43 +18,40 @@ jobs: run: | sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer xcodebuild -version - swift --version + xcodebuild -showsdks | grep iphoneos - - name: Setup Node 20 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: ${{ env.WORKING_DIR }}/package-lock.json + node-version: "20" - - name: Install Node dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm ci + - name: Install deps + working-directory: ./mobile + run: bun install --no-frozen-lockfile - - name: Setup Ruby 3.3 - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.3' + - name: Setup env + working-directory: ./mobile + run: cp .env.example .env - - name: Install CocoaPods gem - run: gem install cocoapods --no-document + - name: Expo prebuild + working-directory: ./mobile + run: bun expo prebuild --platform ios - - name: Cache CocoaPods - uses: actions/cache@v4 - id: pods-cache - with: - path: ${{ env.IOS_DIR }}/Pods - key: ${{ runner.os }}-pods-${{ hashFiles(format('{0}/Podfile.lock', env.IOS_DIR)) }} - restore-keys: | - ${{ runner.os }}-pods- + - 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: Pod install - if: steps.pods-cache.outputs.cache-hit != 'true' - working-directory: ${{ env.IOS_DIR }} - run: pod install --repo-update + - name: Install pods + working-directory: ./mobile/ios + run: pod install - name: Fix Pod deployment targets - working-directory: ${{ env.IOS_DIR }} + working-directory: ./mobile/ios run: | find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do sed -i '' \ @@ -70,24 +61,32 @@ jobs: "$f" done - - name: Build archive - working-directory: ${{ env.IOS_DIR }} + - 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 MentraOSGO2.xcworkspace \ - -scheme MentraOSGO2 \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ -configuration Release \ - -archivePath "$RUNNER_TEMP/MentraOSGO2.xcarchive" \ - -destination "generic/platform=iOS" \ + -destination 'generic/platform=iOS' \ + -sdk iphoneos \ + -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + AD_HOC_CODE_SIGNING_ALLOWED=YES \ + DEVELOPMENT_TEAM="" \ IPHONEOS_DEPLOYMENT_TARGET=13.0 \ SWIFT_COMPILATION_MODE=singlefile \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY="" \ - PROVISIONING_PROFILE_SPECIFIER="" \ - 2>&1 | tee /tmp/xcodebuild.log | xcpretty --color --report html --output /tmp/build-report.html + 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} @@ -98,20 +97,49 @@ jobs: exit "$BUILD_RESULT" fi - - name: Upload full build log (always) + echo "✅ xcodebuild succeeded" + + - name: Show last 100 lines of build log if: always() + run: tail -100 /tmp/xcodebuild.log || echo "No log file" + + - 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" + find "$GITHUB_WORKSPACE/Mentra.xcarchive" -maxdepth 5 2>/dev/null || echo "No xcarchive found" + exit 1 + fi + echo "✅ Found: $APP_PATH" + ls -la "$APP_PATH" + if [ ! -f "$APP_PATH/Info.plist" ]; then + echo "❌ Info.plist missing" + exit 1 + fi + echo "✅ Info.plist present" + 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 + echo "✅ IPA packaged" + ls -lh Mentra.ipa + + - name: Upload IPA uses: actions/upload-artifact@v4 with: - name: xcodebuild-log - path: | - /tmp/xcodebuild.log - /tmp/build-report.html + name: Mentra-SideStore-IPA + path: Mentra.ipa retention-days: 14 - - name: Upload archive - if: success() + - name: Upload build log + if: always() uses: actions/upload-artifact@v4 with: - name: MentraOSGO2-archive - path: ${{ runner.temp }}/MentraOSGO2.xcarchive - retention-days: 30 + name: xcodebuild-log + path: /tmp/xcodebuild.log + retention-days: 5 From 248802823c629d62c37f645ccedf4add9f223da4 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:17:52 +1200 Subject: [PATCH 26/73] Change Xcode version and update pod deployment targets Updated Xcode version from 16.4 to 16.2 and modified pod deployment targets to 13.0. Enhanced build log output and adjusted artifact upload settings. --- .github/workflows/ios-unsigned-ipa.yml | 33 ++++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 93878973f4..a8ab1320f0 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -14,11 +14,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 16.4 + - name: Select Xcode 16.2 run: | - sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer xcodebuild -version - xcodebuild -showsdks | grep iphoneos + swift --version - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -50,15 +50,23 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Fix Pod deployment targets + - name: Patch Pod xcconfigs working-directory: ./mobile/ios run: | find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do + # Fix deployment targets sed -i '' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 10\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 11\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ "$f" + # Force Swift 5 mode on every pod — prevents EmitSwiftModule failures + # from pods that haven't been updated for Swift 6 (RNScreens etc) + if grep -q "SWIFT_VERSION" "$f"; then + sed -i '' 's/SWIFT_VERSION = .*/SWIFT_VERSION = 5/g' "$f" + else + echo "SWIFT_VERSION = 5" >> "$f" + fi done - name: List schemes @@ -86,22 +94,22 @@ jobs: DEVELOPMENT_TEAM="" \ IPHONEOS_DEPLOYMENT_TARGET=13.0 \ SWIFT_COMPILATION_MODE=singlefile \ + SWIFT_VERSION=5 \ 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} if [ "$BUILD_RESULT" -ne 0 ]; then echo "" - echo "========== BUILD FAILED — LAST 200 LINES ==========" - tail -200 /tmp/xcodebuild.log + echo "========== ACTUAL COMPILER ERRORS ==========" + grep -E " error: | error:$" /tmp/xcodebuild.log | grep -v "^note:" | head -80 + echo "" + echo "========== FAILED TARGETS ==========" + grep -E "FAILED|failed|EmitSwift|CompileSwift|Archiving" /tmp/xcodebuild.log | tail -20 exit "$BUILD_RESULT" fi - echo "✅ xcodebuild succeeded" - - - name: Show last 100 lines of build log - if: always() - run: tail -100 /tmp/xcodebuild.log || echo "No log file" + echo "✅ Archive succeeded" - name: Verify archive and find .app run: | @@ -112,7 +120,6 @@ jobs: exit 1 fi echo "✅ Found: $APP_PATH" - ls -la "$APP_PATH" if [ ! -f "$APP_PATH/Info.plist" ]; then echo "❌ Info.plist missing" exit 1 @@ -136,7 +143,7 @@ jobs: path: Mentra.ipa retention-days: 14 - - name: Upload build log + - name: Upload full build log if: always() uses: actions/upload-artifact@v4 with: From 8ff2f91dcfb94d03baa943d56d06b884bb7997b0 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:29:47 +1200 Subject: [PATCH 27/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index a8ab1320f0..1b8f8fafdf 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -50,23 +50,15 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Patch Pod xcconfigs + - name: Fix Pod deployment targets only working-directory: ./mobile/ios run: | find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do - # Fix deployment targets sed -i '' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 10\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 11\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ "$f" - # Force Swift 5 mode on every pod — prevents EmitSwiftModule failures - # from pods that haven't been updated for Swift 6 (RNScreens etc) - if grep -q "SWIFT_VERSION" "$f"; then - sed -i '' 's/SWIFT_VERSION = .*/SWIFT_VERSION = 5/g' "$f" - else - echo "SWIFT_VERSION = 5" >> "$f" - fi done - name: List schemes @@ -93,8 +85,6 @@ jobs: AD_HOC_CODE_SIGNING_ALLOWED=YES \ DEVELOPMENT_TEAM="" \ IPHONEOS_DEPLOYMENT_TARGET=13.0 \ - SWIFT_COMPILATION_MODE=singlefile \ - SWIFT_VERSION=5 \ 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} @@ -102,10 +92,10 @@ jobs: if [ "$BUILD_RESULT" -ne 0 ]; then echo "" echo "========== ACTUAL COMPILER ERRORS ==========" - grep -E " error: | error:$" /tmp/xcodebuild.log | grep -v "^note:" | head -80 + grep -E " error: " /tmp/xcodebuild.log | grep -v "^note:" | head -80 echo "" echo "========== FAILED TARGETS ==========" - grep -E "FAILED|failed|EmitSwift|CompileSwift|Archiving" /tmp/xcodebuild.log | tail -20 + grep -E "FAILED|EmitSwift|CompileSwift|Archiving" /tmp/xcodebuild.log | tail -20 exit "$BUILD_RESULT" fi From ed03a443dc29d2f8d355ab736117c5e4d102f6d2 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:15:49 +1200 Subject: [PATCH 28/73] Update mentraos-manager-ios-build.yml --- .github/workflows/mentraos-manager-ios-build.yml | 4 ---- 1 file changed, 4 deletions(-) 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 From 78403bb430bf0b0577c18b77348fa43b324ce825 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:36:06 +1200 Subject: [PATCH 29/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 1b8f8fafdf..a4cd038113 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -50,9 +50,10 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Fix Pod deployment targets only + - name: Patch Pod xcconfigs working-directory: ./mobile/ios run: | + # Fix deployment targets on all pods find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do sed -i '' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ @@ -61,6 +62,19 @@ jobs: "$f" done + # Force singlefile compilation ONLY on SwiftProtobuf + # This prevents OOM when the compiler tries to WMO 80+ .pb.swift files at once + SWIFTPB_DIR="Pods/Target Support Files/SwiftProtobuf" + if [ -d "$SWIFTPB_DIR" ]; then + find "$SWIFTPB_DIR" -name "*.xcconfig" | while read f; do + echo "Patching: $f" + echo "SWIFT_COMPILATION_MODE = singlefile" >> "$f" + done + else + echo "WARNING: SwiftProtobuf xcconfig dir not found at expected path" + find Pods/Target\ Support\ Files -type d | grep -i swift + fi + - name: List schemes working-directory: ./mobile/ios run: xcodebuild -workspace Mentra.xcworkspace -list @@ -92,10 +106,10 @@ jobs: if [ "$BUILD_RESULT" -ne 0 ]; then echo "" echo "========== ACTUAL COMPILER ERRORS ==========" - grep -E " error: " /tmp/xcodebuild.log | grep -v "^note:" | head -80 + grep ": error:" /tmp/xcodebuild.log | head -50 echo "" echo "========== FAILED TARGETS ==========" - grep -E "FAILED|EmitSwift|CompileSwift|Archiving" /tmp/xcodebuild.log | tail -20 + grep -E "FAILED|EmitSwift|CompileSwift" /tmp/xcodebuild.log | tail -20 exit "$BUILD_RESULT" fi @@ -114,7 +128,6 @@ jobs: echo "❌ Info.plist missing" exit 1 fi - echo "✅ Info.plist present" echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV - name: Package IPA @@ -123,7 +136,6 @@ jobs: cp -r "${{ env.APP_PATH }}" Payload/App.app touch Payload/App.app/embedded.mobileprovision zip -r Mentra.ipa Payload - echo "✅ IPA packaged" ls -lh Mentra.ipa - name: Upload IPA From c82ce0e331f391cf70eee1cedd31a127e7a05548 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:29:52 +1200 Subject: [PATCH 30/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index a4cd038113..27b808543b 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -20,6 +20,9 @@ jobs: xcodebuild -version swift --version + - name: Download iOS platform + run: xcodebuild -downloadPlatform iOS + - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -53,7 +56,6 @@ jobs: - name: Patch Pod xcconfigs working-directory: ./mobile/ios run: | - # Fix deployment targets on all pods find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do sed -i '' \ -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ @@ -62,17 +64,11 @@ jobs: "$f" done - # Force singlefile compilation ONLY on SwiftProtobuf - # This prevents OOM when the compiler tries to WMO 80+ .pb.swift files at once SWIFTPB_DIR="Pods/Target Support Files/SwiftProtobuf" if [ -d "$SWIFTPB_DIR" ]; then find "$SWIFTPB_DIR" -name "*.xcconfig" | while read f; do - echo "Patching: $f" echo "SWIFT_COMPILATION_MODE = singlefile" >> "$f" done - else - echo "WARNING: SwiftProtobuf xcconfig dir not found at expected path" - find Pods/Target\ Support\ Files -type d | grep -i swift fi - name: List schemes @@ -104,10 +100,8 @@ jobs: BUILD_RESULT=${PIPESTATUS[0]} if [ "$BUILD_RESULT" -ne 0 ]; then - echo "" echo "========== ACTUAL COMPILER ERRORS ==========" grep ": error:" /tmp/xcodebuild.log | head -50 - echo "" echo "========== FAILED TARGETS ==========" grep -E "FAILED|EmitSwift|CompileSwift" /tmp/xcodebuild.log | tail -20 exit "$BUILD_RESULT" From 1468e22e845c8832fcd2c5055cd0d4293efae64b Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:14:32 +1200 Subject: [PATCH 31/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 27b808543b..29f05df91a 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -20,6 +20,10 @@ jobs: xcodebuild -version swift --version + - name: Xcode first launch setup + run: sudo xcodebuild -runFirstLaunch + + - name: Download iOS platform run: xcodebuild -downloadPlatform iOS From c577c39455a9f831b89aa0a1f90fab74567aa8c6 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:18:50 +1200 Subject: [PATCH 32/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 59 ++++++++++++++++---------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 29f05df91a..7c116f0944 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -23,10 +23,6 @@ jobs: - name: Xcode first launch setup run: sudo xcodebuild -runFirstLaunch - - - name: Download iOS platform - run: xcodebuild -downloadPlatform iOS - - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -57,23 +53,31 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Patch Pod xcconfigs + - name: Patch Pods project via xcodeproj working-directory: ./mobile/ios run: | - find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do - sed -i '' \ - -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ - -e 's/IPHONEOS_DEPLOYMENT_TARGET = 10\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ - -e 's/IPHONEOS_DEPLOYMENT_TARGET = 11\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ - "$f" - done - - SWIFTPB_DIR="Pods/Target Support Files/SwiftProtobuf" - if [ -d "$SWIFTPB_DIR" ]; then - find "$SWIFTPB_DIR" -name "*.xcconfig" | while read f; do - echo "SWIFT_COMPILATION_MODE = singlefile" >> "$f" - done - fi + ruby << 'RUBY' + require 'xcodeproj' + + project = Xcodeproj::Project.open('Pods/Pods.xcodeproj') + + project.targets.each do |target| + target.build_configurations.each do |config| + # Fix deployment targets for all pods + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 13.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + end + # Force singlefile compilation on SwiftProtobuf specifically + if target.name == 'SwiftProtobuf' + config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' + puts "Patched SwiftProtobuf #{config.name}: SWIFT_COMPILATION_MODE=singlefile" + end + end + end + + project.save + puts "Pods.xcodeproj saved successfully" + RUBY - name: List schemes working-directory: ./mobile/ios @@ -104,10 +108,19 @@ jobs: BUILD_RESULT=${PIPESTATUS[0]} if [ "$BUILD_RESULT" -ne 0 ]; then - echo "========== ACTUAL COMPILER ERRORS ==========" - grep ": error:" /tmp/xcodebuild.log | head -50 - echo "========== FAILED TARGETS ==========" - grep -E "FAILED|EmitSwift|CompileSwift" /tmp/xcodebuild.log | tail -20 + echo "========== ALL ERROR LINES ==========" + grep -E "error:|Killed|signal|crash|failed|cannot|unable" /tmp/xcodebuild.log \ + | grep -v "^note:" | head -80 + echo "" + echo "========== 50 LINES BEFORE SwiftProtobuf FAILURE ==========" + grep -n "SwiftProtobuf" /tmp/xcodebuild.log \ + | grep -i "error\|fail\|emit\|compile" \ + | head -5 \ + | awk -F: '{print $1}' \ + | while read linenum; do + start=$((linenum > 50 ? linenum - 50 : 1)) + sed -n "${start},${linenum}p" /tmp/xcodebuild.log + done exit "$BUILD_RESULT" fi From 648fce06ab8f1334e975cf3873e1d43495aa9f28 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:30:18 +1200 Subject: [PATCH 33/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 44 +++++++++++--------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 7c116f0944..5bb9e16e30 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -14,15 +14,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 16.2 + - name: Confirm Xcode version run: | - sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer xcodebuild -version swift --version - - name: Xcode first launch setup - run: sudo xcodebuild -runFirstLaunch - - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -53,7 +49,7 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Patch Pods project via xcodeproj + - name: Patch Pods project working-directory: ./mobile/ios run: | ruby << 'RUBY' @@ -63,20 +59,28 @@ jobs: project.targets.each do |target| target.build_configurations.each do |config| - # Fix deployment targets for all pods - if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 13.0 + # Fix low deployment targets + current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f + if current > 0 && current < 13.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' end - # Force singlefile compilation on SwiftProtobuf specifically + + # SwiftProtobuf: singlefile prevents OOM compiling 80+ files via WMO if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' - puts "Patched SwiftProtobuf #{config.name}: SWIFT_COMPILATION_MODE=singlefile" + puts "SwiftProtobuf #{config.name}: singlefile compilation set" + end + + # All Swift pods: disable strict concurrency so Swift 6 (Xcode 16.4) + # does not break RNScreens and other RN pods at EmitSwiftModule + if config.build_settings['SWIFT_VERSION'] + config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' end end end project.save - puts "Pods.xcodeproj saved successfully" + puts "Pods.xcodeproj patched and saved" RUBY - name: List schemes @@ -95,7 +99,6 @@ jobs: -scheme Mentra \ -configuration Release \ -destination 'generic/platform=iOS' \ - -sdk iphoneos \ -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ @@ -108,19 +111,10 @@ jobs: BUILD_RESULT=${PIPESTATUS[0]} if [ "$BUILD_RESULT" -ne 0 ]; then - echo "========== ALL ERROR LINES ==========" - grep -E "error:|Killed|signal|crash|failed|cannot|unable" /tmp/xcodebuild.log \ - | grep -v "^note:" | head -80 - echo "" - echo "========== 50 LINES BEFORE SwiftProtobuf FAILURE ==========" - grep -n "SwiftProtobuf" /tmp/xcodebuild.log \ - | grep -i "error\|fail\|emit\|compile" \ - | head -5 \ - | awk -F: '{print $1}' \ - | while read linenum; do - start=$((linenum > 50 ? linenum - 50 : 1)) - sed -n "${start},${linenum}p" /tmp/xcodebuild.log - done + echo "========== ACTUAL ERRORS ==========" + grep ": error:" /tmp/xcodebuild.log | grep -v "^note:" | head -80 + echo "========== FAILED TARGETS ==========" + grep -E "FAILED|EmitSwift|CompileSwift" /tmp/xcodebuild.log | tail -20 exit "$BUILD_RESULT" fi From 3f02966830d380e55948a4770ff90f3cd7d91cf1 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:46:53 +1200 Subject: [PATCH 34/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 5bb9e16e30..14f9e2fe1f 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -59,30 +59,32 @@ jobs: project.targets.each do |target| target.build_configurations.each do |config| - # Fix low deployment targets + # Fix low deployment targets on all pods current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f if current > 0 && current < 13.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' end - # SwiftProtobuf: singlefile prevents OOM compiling 80+ files via WMO - if target.name == 'SwiftProtobuf' + case target.name + when 'SwiftProtobuf' + # WMO OOM-kills compiler on 80+ files — force singlefile config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' - puts "SwiftProtobuf #{config.name}: singlefile compilation set" - end + puts "SwiftProtobuf #{config.name}: singlefile set" - # All Swift pods: disable strict concurrency so Swift 6 (Xcode 16.4) - # does not break RNScreens and other RN pods at EmitSwiftModule - if config.build_settings['SWIFT_VERSION'] + when 'RNScreens' + # gamma/ files have Swift 6 incompatibilities — compile in Swift 5 mode + config.build_settings['SWIFT_VERSION'] = '5' config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' + puts "RNScreens #{config.name}: SWIFT_VERSION=5, strict-concurrency=minimal" end end end project.save - puts "Pods.xcodeproj patched and saved" + puts "Done" RUBY + - name: List schemes working-directory: ./mobile/ios run: xcodebuild -workspace Mentra.xcworkspace -list From 61d2f00132313537e24858085d7bfdcf30a1c60d Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:06:27 +1200 Subject: [PATCH 35/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 47 +++++++++++++------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 14f9e2fe1f..a8418daf7e 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -49,41 +49,42 @@ jobs: working-directory: ./mobile/ios run: pod install - - name: Patch Pods project + - name: Patch Pods working-directory: ./mobile/ios run: | + # Fix RNScreens xcconfig directly — xcconfig wins over pbxproj for SWIFT_VERSION + # gamma/ files fail under Swift 6 (Xcode 16.4), force Swift 5 mode + find "Pods/Target Support Files/RNScreens" -name "*.xcconfig" | while read f; do + sed -i '' '/^SWIFT_VERSION/d' "$f" + sed -i '' '/^SWIFT_STRICT_CONCURRENCY/d' "$f" + echo "SWIFT_VERSION = 5" >> "$f" + echo "SWIFT_STRICT_CONCURRENCY = minimal" >> "$f" + echo "Patched RNScreens xcconfig: $f" + done + + # Fix SwiftProtobuf via xcodeproj — singlefile prevents OOM on 80+ files ruby << 'RUBY' require 'xcodeproj' - project = Xcodeproj::Project.open('Pods/Pods.xcodeproj') - project.targets.each do |target| + next unless target.name == 'SwiftProtobuf' target.build_configurations.each do |config| - # Fix low deployment targets on all pods - current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f - if current > 0 && current < 13.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' - end - - case target.name - when 'SwiftProtobuf' - # WMO OOM-kills compiler on 80+ files — force singlefile - config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' - puts "SwiftProtobuf #{config.name}: singlefile set" - - when 'RNScreens' - # gamma/ files have Swift 6 incompatibilities — compile in Swift 5 mode - config.build_settings['SWIFT_VERSION'] = '5' - config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' - puts "RNScreens #{config.name}: SWIFT_VERSION=5, strict-concurrency=minimal" - end + config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' + puts "SwiftProtobuf #{config.name}: singlefile set" end end - project.save - puts "Done" RUBY + # Fix low deployment targets on all pods + find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do + sed -i '' \ + -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ + -e 's/IPHONEOS_DEPLOYMENT_TARGET = 10\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ + -e 's/IPHONEOS_DEPLOYMENT_TARGET = 11\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ + "$f" + done + - name: List schemes working-directory: ./mobile/ios From 23d7ccd05e3bad7cb3362f7ef85e381958380389 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:23:25 +1200 Subject: [PATCH 36/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 68 ++++++++++++-------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index a8418daf7e..772fa68a6e 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -45,46 +45,40 @@ jobs: chmod +x scripts/setup-sherpa-onnx-optional.sh ./scripts/setup-sherpa-onnx-optional.sh || true - - name: Install pods - working-directory: ./mobile/ios - run: pod install - - - name: Patch Pods + - name: Inject Podfile post_install hook working-directory: ./mobile/ios run: | - # Fix RNScreens xcconfig directly — xcconfig wins over pbxproj for SWIFT_VERSION - # gamma/ files fail under Swift 6 (Xcode 16.4), force Swift 5 mode - find "Pods/Target Support Files/RNScreens" -name "*.xcconfig" | while read f; do - sed -i '' '/^SWIFT_VERSION/d' "$f" - sed -i '' '/^SWIFT_STRICT_CONCURRENCY/d' "$f" - echo "SWIFT_VERSION = 5" >> "$f" - echo "SWIFT_STRICT_CONCURRENCY = minimal" >> "$f" - echo "Patched RNScreens xcconfig: $f" - done - - # Fix SwiftProtobuf via xcodeproj — singlefile prevents OOM on 80+ files - ruby << 'RUBY' - require 'xcodeproj' - project = Xcodeproj::Project.open('Pods/Pods.xcodeproj') - project.targets.each do |target| - next unless target.name == 'SwiftProtobuf' - target.build_configurations.each do |config| - config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' - puts "SwiftProtobuf #{config.name}: singlefile set" - end - end - project.save - RUBY - - # Fix low deployment targets on all pods - find Pods/Target\ Support\ Files -name "*.xcconfig" | while read f; do - sed -i '' \ - -e 's/IPHONEOS_DEPLOYMENT_TARGET = 9\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ - -e 's/IPHONEOS_DEPLOYMENT_TARGET = 10\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ - -e 's/IPHONEOS_DEPLOYMENT_TARGET = 11\.0/IPHONEOS_DEPLOYMENT_TARGET = 13.0/g' \ - "$f" - done + cat >> Podfile << 'EOF' + +# CI: Fix Swift 6 (Xcode 16.4) incompatibilities +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + # Fix pods with deployment targets below iOS 13 + dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if dt && dt.to_f < 13.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + end + # RNScreens gamma/ files use @MainActor patterns that fail under Swift 6 + # Setting SWIFT_VERSION=5.0 disables Swift 6 strict concurrency for this target + if target.name == 'RNScreens' + config.build_settings['SWIFT_VERSION'] = '5.0' + config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' + end + # SwiftProtobuf compiles 80+ files — WMO causes OOM, force singlefile + if target.name == 'SwiftProtobuf' + config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' + end + end + end +end +EOF + echo "Podfile post_install hook injected" + tail -20 Podfile + - name: Install pods + working-directory: ./mobile/ios + run: pod install - name: List schemes working-directory: ./mobile/ios From 0e90bfc9200ed234b19a81db0f69f6cf8d4675e8 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:30:09 +1200 Subject: [PATCH 37/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 69 +++++++++++++++----------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 772fa68a6e..b6de7e5c2a 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -45,36 +45,49 @@ jobs: chmod +x scripts/setup-sherpa-onnx-optional.sh ./scripts/setup-sherpa-onnx-optional.sh || true - - name: Inject Podfile post_install hook + - name: Patch Podfile working-directory: ./mobile/ios run: | - cat >> Podfile << 'EOF' - -# CI: Fix Swift 6 (Xcode 16.4) incompatibilities -post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - # Fix pods with deployment targets below iOS 13 - dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] - if dt && dt.to_f < 13.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' - end - # RNScreens gamma/ files use @MainActor patterns that fail under Swift 6 - # Setting SWIFT_VERSION=5.0 disables Swift 6 strict concurrency for this target - if target.name == 'RNScreens' - config.build_settings['SWIFT_VERSION'] = '5.0' - config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' - end - # SwiftProtobuf compiles 80+ files — WMO causes OOM, force singlefile - if target.name == 'SwiftProtobuf' - config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' - end - end - end -end -EOF - echo "Podfile post_install hook injected" - tail -20 Podfile + python3 << 'PYEOF' + with open('Podfile', 'r') as f: + content = f.read() + + injection = """ + # CI patch: fix Swift 6 / Xcode 16.4 incompatibilities + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if dt && dt.to_f < 13.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + end + if target.name == 'RNScreens' + config.build_settings['SWIFT_VERSION'] = '5.0' + 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) + + print("Podfile patched successfully") + PYEOF + + - name: Show patched Podfile post_install + working-directory: ./mobile/ios + run: grep -A 30 "post_install" Podfile | head -40 - name: Install pods working-directory: ./mobile/ios From f4ddd07028fb0f2d2a496941123ad81740497995 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:59:10 +1200 Subject: [PATCH 38/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 54 +++++++++++++++++++++----- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index b6de7e5c2a..f9ad2f9762 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -53,17 +53,23 @@ jobs: content = f.read() injection = """ - # CI patch: fix Swift 6 / Xcode 16.4 incompatibilities installer.pods_project.targets.each do |target| target.build_configurations.each do |config| + # Fix low deployment targets dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] if dt && dt.to_f < 13.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' end + # RNScreens gamma/ files ARE Swift 6 syntax - do NOT set SWIFT_VERSION=5 + # Reduce concurrency strictness only via compiler flag if target.name == 'RNScreens' - config.build_settings['SWIFT_VERSION'] = '5.0' config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' + flags = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' + unless flags.include?('-strict-concurrency') + config.build_settings['OTHER_SWIFT_FLAGS'] = flags + ' -strict-concurrency=minimal' + end end + # SwiftProtobuf: WMO OOM-kills compiler on 80+ files if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end @@ -81,13 +87,11 @@ jobs: with open('Podfile', 'w') as f: f.write(content) - - print("Podfile patched successfully") PYEOF - name: Show patched Podfile post_install working-directory: ./mobile/ios - run: grep -A 30 "post_install" Podfile | head -40 + run: grep -A 40 "post_install" Podfile | head -50 - name: Install pods working-directory: ./mobile/ios @@ -110,6 +114,7 @@ jobs: -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 \ @@ -121,10 +126,32 @@ jobs: BUILD_RESULT=${PIPESTATUS[0]} if [ "$BUILD_RESULT" -ne 0 ]; then - echo "========== ACTUAL ERRORS ==========" - grep ": error:" /tmp/xcodebuild.log | grep -v "^note:" | head -80 - echo "========== FAILED TARGETS ==========" - grep -E "FAILED|EmitSwift|CompileSwift" /tmp/xcodebuild.log | tail -20 + echo "========== REAL ERRORS FROM XCRESULT ==========" + xcrun xcresulttool get --format json --path /tmp/build.xcresult 2>/dev/null | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + def walk(obj): + if isinstance(obj, dict): + t = obj.get('type', {}).get('_name', '') + if 'Issue' in t or 'Warning' in t or 'Error' in t: + msg = obj.get('message', {}).get('_value', '') + loc = obj.get('documentLocationInCreatingWorkspace', {}) + url = loc.get('url', {}).get('_value', '') if isinstance(loc, dict) else '' + if msg: + print(f'{url}: {msg}' if url else msg) + for v in obj.values(): + walk(v) + elif isinstance(obj, list): + for i in obj: + walk(i) + walk(data) +except Exception as e: + print(f'Parse error: {e}') +" || true + echo "" + echo "========== GREP ERRORS ==========" + grep -E "error:|warning:.*error" /tmp/xcodebuild.log | grep -v "^note:" | head -50 exit "$BUILD_RESULT" fi @@ -135,7 +162,6 @@ jobs: APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) if [ -z "$APP_PATH" ]; then echo "❌ Mentra.app not found" - find "$GITHUB_WORKSPACE/Mentra.xcarchive" -maxdepth 5 2>/dev/null || echo "No xcarchive found" exit 1 fi echo "✅ Found: $APP_PATH" @@ -160,6 +186,14 @@ jobs: 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 full build log if: always() uses: actions/upload-artifact@v4 From ebabc0c8e4e28131967a30688229043513489c8a Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:02:32 +1200 Subject: [PATCH 39/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 48 ++++---------------------- 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index f9ad2f9762..4530ca2c23 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -55,22 +55,11 @@ jobs: injection = """ installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - # Fix low deployment targets dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] if dt && dt.to_f < 13.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' end - # RNScreens gamma/ files ARE Swift 6 syntax - do NOT set SWIFT_VERSION=5 - # Reduce concurrency strictness only via compiler flag - if target.name == 'RNScreens' - config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' - flags = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' - unless flags.include?('-strict-concurrency') - config.build_settings['OTHER_SWIFT_FLAGS'] = flags + ' -strict-concurrency=minimal' - end - end - # SwiftProtobuf: WMO OOM-kills compiler on 80+ files - if target.name == 'SwiftProtobuf' + if ['SwiftProtobuf', 'RNScreens'].include?(target.name) config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end end @@ -89,9 +78,9 @@ jobs: f.write(content) PYEOF - - name: Show patched Podfile post_install + - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 40 "post_install" Podfile | head -50 + run: grep -A 20 "post_install" Podfile | head -30 - name: Install pods working-directory: ./mobile/ios @@ -126,32 +115,9 @@ jobs: BUILD_RESULT=${PIPESTATUS[0]} if [ "$BUILD_RESULT" -ne 0 ]; then - echo "========== REAL ERRORS FROM XCRESULT ==========" - xcrun xcresulttool get --format json --path /tmp/build.xcresult 2>/dev/null | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - def walk(obj): - if isinstance(obj, dict): - t = obj.get('type', {}).get('_name', '') - if 'Issue' in t or 'Warning' in t or 'Error' in t: - msg = obj.get('message', {}).get('_value', '') - loc = obj.get('documentLocationInCreatingWorkspace', {}) - url = loc.get('url', {}).get('_value', '') if isinstance(loc, dict) else '' - if msg: - print(f'{url}: {msg}' if url else msg) - for v in obj.values(): - walk(v) - elif isinstance(obj, list): - for i in obj: - walk(i) - walk(data) -except Exception as e: - print(f'Parse error: {e}') -" || true - echo "" - echo "========== GREP ERRORS ==========" - grep -E "error:|warning:.*error" /tmp/xcodebuild.log | grep -v "^note:" | head -50 + echo "========== ERRORS ==========" + grep -E "error:|Killed|Signal|signal [0-9]" /tmp/xcodebuild.log \ + | grep -v "^note:" | head -80 exit "$BUILD_RESULT" fi @@ -194,7 +160,7 @@ except Exception as e: path: /tmp/build.xcresult retention-days: 5 - - name: Upload full build log + - name: Upload build log if: always() uses: actions/upload-artifact@v4 with: From f3bd89426771e57f54ce5a231949b720c60a5168 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:15:44 +1200 Subject: [PATCH 40/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 4530ca2c23..d34c01e525 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -59,7 +59,10 @@ jobs: if dt && dt.to_f < 13.0 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' end - if ['SwiftProtobuf', 'RNScreens'].include?(target.name) + if target.name == 'RNScreens' + config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -Xfrontend -disable-actor-data-race-checks' + end + if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end end @@ -80,7 +83,7 @@ jobs: - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 20 "post_install" Podfile | head -30 + run: grep -A 25 "post_install" Podfile | head -35 - name: Install pods working-directory: ./mobile/ios @@ -116,8 +119,7 @@ jobs: if [ "$BUILD_RESULT" -ne 0 ]; then echo "========== ERRORS ==========" - grep -E "error:|Killed|Signal|signal [0-9]" /tmp/xcodebuild.log \ - | grep -v "^note:" | head -80 + grep ": error:" /tmp/xcodebuild.log | grep -v "^note:" | head -80 exit "$BUILD_RESULT" fi From 5a2f38650ea071be27117ba8790f287ae7bd45e9 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:32:50 +1200 Subject: [PATCH 41/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index d34c01e525..21ef88b729 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -56,8 +56,8 @@ jobs: installer.pods_project.targets.each do |target| target.build_configurations.each do |config| dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] - if dt && dt.to_f < 13.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + if dt && dt.to_f < 14.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' end if target.name == 'RNScreens' config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -Xfrontend -disable-actor-data-race-checks' @@ -112,7 +112,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ AD_HOC_CODE_SIGNING_ALLOWED=YES \ DEVELOPMENT_TEAM="" \ - IPHONEOS_DEPLOYMENT_TARGET=13.0 \ + IPHONEOS_DEPLOYMENT_TARGET=14.0 \ 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} From 3d36df7075aaa546473cd0243453237233209117 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:46:07 +1200 Subject: [PATCH 42/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 21ef88b729..ed8369f9b0 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -56,8 +56,8 @@ jobs: installer.pods_project.targets.each do |target| target.build_configurations.each do |config| dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] - if dt && dt.to_f < 14.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + if dt && dt.to_f < 14.5 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.5' end if target.name == 'RNScreens' config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -Xfrontend -disable-actor-data-race-checks' @@ -112,7 +112,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ AD_HOC_CODE_SIGNING_ALLOWED=YES \ DEVELOPMENT_TEAM="" \ - IPHONEOS_DEPLOYMENT_TARGET=14.0 \ + IPHONEOS_DEPLOYMENT_TARGET=14.5 \ 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} From 3ca99ad6ea98ad8de76119c24b204902561a57b4 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:05:53 +1200 Subject: [PATCH 43/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index ed8369f9b0..ca1361dc1d 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -55,13 +55,20 @@ jobs: injection = """ installer.pods_project.targets.each do |target| target.build_configurations.each do |config| + # Raise all pods to iOS 14.5 minimum dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] if dt && dt.to_f < 14.5 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.5' end - if target.name == 'RNScreens' - config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -Xfrontend -disable-actor-data-race-checks' + # Apply Swift 6 actor isolation fix to ALL Swift pods + # Prevents whack-a-mole across RNScreens, ExpoModulesCore, etc. + if config.build_settings['SWIFT_VERSION'] + existing = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' + unless existing.include?('-disable-actor-data-race-checks') + config.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-actor-data-race-checks' + end end + # SwiftProtobuf WMO causes OOM on 80+ files if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end @@ -83,7 +90,7 @@ jobs: - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 25 "post_install" Podfile | head -35 + run: grep -A 30 "post_install" Podfile | head -40 - name: Install pods working-directory: ./mobile/ios From 7305dce7a8c11d318e0ef61b6d3a5818e800414c Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:23:39 +1200 Subject: [PATCH 44/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index ca1361dc1d..6780c5c607 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -60,15 +60,22 @@ jobs: if dt && dt.to_f < 14.5 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.5' end - # Apply Swift 6 actor isolation fix to ALL Swift pods - # Prevents whack-a-mole across RNScreens, ExpoModulesCore, etc. + # Add actor race checks disable flag to all Swift pods if config.build_settings['SWIFT_VERSION'] existing = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' unless existing.include?('-disable-actor-data-race-checks') config.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-actor-data-race-checks' end end - # SwiftProtobuf WMO causes OOM on 80+ files + # ExpoModulesCore is compiled in Swift 6 mode by CocoaPods + # Swift 6 strict concurrency causes compile-time errors that + # -disable-actor-data-race-checks does not suppress + # Downgrade to Swift 5 mode with minimal concurrency checking + if target.name == 'ExpoModulesCore' + config.build_settings['SWIFT_VERSION'] = '5' + config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' + end + # SwiftProtobuf WMO OOM-kills compiler on 80+ files if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end @@ -90,7 +97,7 @@ jobs: - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 30 "post_install" Podfile | head -40 + run: grep -A 35 "post_install" Podfile | head -45 - name: Install pods working-directory: ./mobile/ios From efc322997a1a405f1307f2d4265abfc0820d1259 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:49:04 +1200 Subject: [PATCH 45/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 44 ++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 6780c5c607..67169b05f8 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -45,6 +45,33 @@ jobs: chmod +x scripts/setup-sherpa-onnx-optional.sh ./scripts/setup-sherpa-onnx-optional.sh || true + - name: Patch ExpoModulesCore Swift 6 syntax + working-directory: ./mobile + run: | + # These 3 files use SE-0431 isolated conformance syntax (@MainActor in conformance + # position) which is Swift 6 only. Strip @MainActor from the conformance position + # so they compile in Swift 5 mode. SWIFT_STRICT_CONCURRENCY=minimal (set in Podfile + # post_install) suppresses the remaining actor isolation errors. + + 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" + + # Line 45: public final class HostingView<...>: ExpoView, @MainActor AnyExpoSwiftUIHostingView { + sed -i '' 's/, @MainActor AnyExpoSwiftUIHostingView/, AnyExpoSwiftUIHostingView/g' "$FILE1" + echo "Patched $FILE1" + grep -n "AnyExpoSwiftUIHostingView" "$FILE1" | head -3 + + # Line 196: extension ExpoSwiftUI.SwiftUIVirtualView: @MainActor ExpoSwiftUI.ViewWrapper { + sed -i '' 's/: @MainActor ExpoSwiftUI\.ViewWrapper/: ExpoSwiftUI.ViewWrapper/g' "$FILE2" + echo "Patched $FILE2" + grep -n "ViewWrapper" "$FILE2" | head -3 + + # Line 121: extension UIView: @MainActor AnyArgument { + sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" + echo "Patched $FILE3" + grep -n "AnyArgument" "$FILE3" | head -3 + - name: Patch Podfile working-directory: ./mobile/ios run: | @@ -60,22 +87,13 @@ jobs: if dt && dt.to_f < 14.5 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.5' end - # Add actor race checks disable flag to all Swift pods - if config.build_settings['SWIFT_VERSION'] - existing = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' - unless existing.include?('-disable-actor-data-race-checks') - config.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-actor-data-race-checks' - end - end - # ExpoModulesCore is compiled in Swift 6 mode by CocoaPods - # Swift 6 strict concurrency causes compile-time errors that - # -disable-actor-data-race-checks does not suppress - # Downgrade to Swift 5 mode with minimal concurrency checking + # ExpoModulesCore: use Swift 5 mode with minimal concurrency checking + # (SE-0431 syntax has been patched out of its source files above) if target.name == 'ExpoModulesCore' config.build_settings['SWIFT_VERSION'] = '5' config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' end - # SwiftProtobuf WMO OOM-kills compiler on 80+ files + # SwiftProtobuf: WMO OOM-kills compiler on 80+ files if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end @@ -97,7 +115,7 @@ jobs: - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 35 "post_install" Podfile | head -45 + run: grep -A 25 "post_install" Podfile | head -35 - name: Install pods working-directory: ./mobile/ios From 6f342775f07c6853e5eb86d1b662c4f20ebf7d22 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:48:30 +1200 Subject: [PATCH 46/73] Update iOS deployment target and patch ExpoModulesCore Updated iOS deployment target to 16.0 and applied patches for ExpoModulesCore. --- .github/workflows/ios-unsigned-ipa.yml | 28 +++++--------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 67169b05f8..d32eb10631 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -48,29 +48,15 @@ jobs: - name: Patch ExpoModulesCore Swift 6 syntax working-directory: ./mobile run: | - # These 3 files use SE-0431 isolated conformance syntax (@MainActor in conformance - # position) which is Swift 6 only. Strip @MainActor from the conformance position - # so they compile in Swift 5 mode. SWIFT_STRICT_CONCURRENCY=minimal (set in Podfile - # post_install) suppresses the remaining actor isolation errors. - 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" - # Line 45: public final class HostingView<...>: ExpoView, @MainActor AnyExpoSwiftUIHostingView { sed -i '' 's/, @MainActor AnyExpoSwiftUIHostingView/, AnyExpoSwiftUIHostingView/g' "$FILE1" - echo "Patched $FILE1" - grep -n "AnyExpoSwiftUIHostingView" "$FILE1" | head -3 - - # Line 196: extension ExpoSwiftUI.SwiftUIVirtualView: @MainActor ExpoSwiftUI.ViewWrapper { sed -i '' 's/: @MainActor ExpoSwiftUI\.ViewWrapper/: ExpoSwiftUI.ViewWrapper/g' "$FILE2" - echo "Patched $FILE2" - grep -n "ViewWrapper" "$FILE2" | head -3 - - # Line 121: extension UIView: @MainActor AnyArgument { sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" - echo "Patched $FILE3" - grep -n "AnyArgument" "$FILE3" | head -3 + + echo "ExpoModulesCore patches applied" - name: Patch Podfile working-directory: ./mobile/ios @@ -82,18 +68,14 @@ jobs: injection = """ installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - # Raise all pods to iOS 14.5 minimum dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] - if dt && dt.to_f < 14.5 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.5' + if dt && dt.to_f < 16.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' end - # ExpoModulesCore: use Swift 5 mode with minimal concurrency checking - # (SE-0431 syntax has been patched out of its source files above) if target.name == 'ExpoModulesCore' config.build_settings['SWIFT_VERSION'] = '5' config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' end - # SwiftProtobuf: WMO OOM-kills compiler on 80+ files if target.name == 'SwiftProtobuf' config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile' end @@ -144,7 +126,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ AD_HOC_CODE_SIGNING_ALLOWED=YES \ DEVELOPMENT_TEAM="" \ - IPHONEOS_DEPLOYMENT_TARGET=14.5 \ + IPHONEOS_DEPLOYMENT_TARGET=16.0 \ 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} From ba09818ad20dc2d825e0e636e42f76216ea9db77 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:06:45 +1200 Subject: [PATCH 47/73] Modify iOS workflow to patch ExpoModulesCore and ExpoVideo Updated patching process for ExpoModulesCore and ExpoVideo in the iOS workflow. --- .github/workflows/ios-unsigned-ipa.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index d32eb10631..9673c2b9e0 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -45,19 +45,24 @@ jobs: chmod +x scripts/setup-sherpa-onnx-optional.sh ./scripts/setup-sherpa-onnx-optional.sh || true - - name: Patch ExpoModulesCore Swift 6 syntax + - name: Patch node_modules source files working-directory: ./mobile run: | + # --- ExpoModulesCore: remove SE-0431 isolated conformance syntax (Swift 6 only) --- 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 in any iOS SDK version --- + TRACKS="node_modules/expo-video/ios/Records/Tracks.swift" + sed -i '' 's/variant\.url/nil/g' "$TRACKS" + echo "ExpoVideo Tracks.swift patched" + grep -n "variant\." "$TRACKS" | head -5 + - name: Patch Podfile working-directory: ./mobile/ios run: | @@ -97,7 +102,7 @@ jobs: - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 25 "post_install" Podfile | head -35 + run: grep -A 20 "post_install" Podfile | head -30 - name: Install pods working-directory: ./mobile/ios From 0e0a18ac0eb3b1fe5d6da0eaaecaf8de71cf9ae5 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:26:30 +1200 Subject: [PATCH 48/73] Fix property name in ExpoVideo Tracks.swift patch --- .github/workflows/ios-unsigned-ipa.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 9673c2b9e0..45aa79dcfe 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -59,10 +59,10 @@ jobs: # --- ExpoVideo: AVAssetVariant has no 'url' property in any iOS SDK version --- TRACKS="node_modules/expo-video/ios/Records/Tracks.swift" - sed -i '' 's/variant\.url/nil/g' "$TRACKS" + sed -i '' 's/assetVariant\.url/nil/g' "$TRACKS" echo "ExpoVideo Tracks.swift patched" - grep -n "variant\." "$TRACKS" | head -5 - + grep -n "assetVariant\." "$TRACKS" | head -5 + - name: Patch Podfile working-directory: ./mobile/ios run: | From 12134a3b4e28aaec58e8857d9fcb038257020f74 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:34:39 +1200 Subject: [PATCH 49/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 176 ++++++++++--------------- 1 file changed, 69 insertions(+), 107 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 45aa79dcfe..de6b916f52 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -8,12 +8,16 @@ on: jobs: build: - runs-on: macos-15 + runs-on: macos-13 # important: stable runner with Xcode 15 steps: - name: Checkout uses: actions/checkout@v4 + # ✅ Force Xcode 15 (avoids Swift 6 entirely) + - name: Select Xcode 15.4 + run: sudo xcode-select -s /Applications/Xcode_15.4.app/Contents/Developer + - name: Confirm Xcode version run: | xcodebuild -version @@ -37,154 +41,112 @@ jobs: - name: Expo prebuild working-directory: ./mobile - run: bun expo prebuild --platform ios + run: npx 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 + # ✅ Install CocoaPods cleanly + - name: Install CocoaPods + run: sudo gem install cocoapods - - name: Patch node_modules source files - working-directory: ./mobile - run: | - # --- ExpoModulesCore: remove SE-0431 isolated conformance syntax (Swift 6 only) --- - 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 in any iOS SDK version --- - TRACKS="node_modules/expo-video/ios/Records/Tracks.swift" - sed -i '' 's/assetVariant\.url/nil/g' "$TRACKS" - echo "ExpoVideo Tracks.swift patched" - grep -n "assetVariant\." "$TRACKS" | head -5 - - - name: Patch Podfile + # ✅ Apply SAFE Podfile patch (no duplication) + - name: Patch Podfile (Swift 5 fix) 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| - dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] - if dt && dt.to_f < 16.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' - end - 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' + ruby <<'RUBY' + file = 'Podfile' + content = File.read(file) + + unless content.include?("SWIFT_VERSION") + injection = <<~EOS + + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['SWIFT_VERSION'] = '5.0' + config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' + config.build_settings['SWIFT_TREAT_WARNINGS_AS_ERRORS'] = 'NO' + end 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 + EOS + + content += injection + File.write(file, content) + puts "✅ Podfile patched" + else + puts "ℹ️ Podfile already patched" + end + RUBY - name: Install pods working-directory: ./mobile/ios - run: pod install + run: pod install --repo-update - - name: List schemes + # ✅ Dynamically detect workspace + scheme + - name: Detect workspace and scheme working-directory: ./mobile/ios - run: xcodebuild -workspace Mentra.xcworkspace -list + run: | + WORKSPACE=$(ls *.xcworkspace | head -1) + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + + SCHEME=$(xcodebuild -workspace "$WORKSPACE" -list | awk '/Schemes:/ {getline; print $1}') + echo "SCHEME=$SCHEME" >> $GITHUB_ENV + + echo "Using workspace: $WORKSPACE" + echo "Using scheme: $SCHEME" - name: Archive app working-directory: ./mobile/ios - env: - SENTRY_DISABLE_AUTO_UPLOAD: "true" run: | set -o pipefail xcodebuild archive \ - -workspace Mentra.xcworkspace \ - -scheme Mentra \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ -configuration Release \ -destination 'generic/platform=iOS' \ - -archivePath "$GITHUB_WORKSPACE/Mentra.xcarchive" \ - -resultBundlePath /tmp/build.xcresult \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ + -archivePath "$GITHUB_WORKSPACE/App.xcarchive" \ CODE_SIGNING_ALLOWED=NO \ - AD_HOC_CODE_SIGNING_ALLOWED=YES \ - DEVELOPMENT_TEAM="" \ - IPHONEOS_DEPLOYMENT_TARGET=16.0 \ 2>&1 | tee /tmp/xcodebuild.log - BUILD_RESULT=${PIPESTATUS[0]} + EXIT_CODE=${PIPESTATUS[0]} - if [ "$BUILD_RESULT" -ne 0 ]; then - echo "========== ERRORS ==========" - grep ": error:" /tmp/xcodebuild.log | grep -v "^note:" | head -80 - exit "$BUILD_RESULT" + if [ $EXIT_CODE -ne 0 ]; then + echo "❌ Build failed" + grep ": error:" /tmp/xcodebuild.log | head -50 + exit $EXIT_CODE fi echo "✅ Archive succeeded" - - name: Verify archive and find .app + - name: Extract .app run: | - APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) + APP_PATH=$(find $GITHUB_WORKSPACE -name "*.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" + echo "❌ .app not found" exit 1 fi + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + echo "Found app: $APP_PATH" + # ✅ SideStore-compatible IPA packaging - 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 + mkdir Payload + cp -r "$APP_PATH" Payload/App.app + zip -r App.ipa Payload + ls -lh App.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: SideStore-IPA + path: App.ipa - - name: Upload build log + - name: Upload logs if: always() uses: actions/upload-artifact@v4 with: - name: xcodebuild-log + name: logs path: /tmp/xcodebuild.log - retention-days: 5 From 1ac34cb5f077fbfa8e481df7c3c92f36d4354e50 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:28:06 +1200 Subject: [PATCH 50/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 175 +++++++++++++++---------- 1 file changed, 106 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index de6b916f52..e340426678 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -8,16 +8,12 @@ on: jobs: build: - runs-on: macos-13 # important: stable runner with Xcode 15 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - # ✅ Force Xcode 15 (avoids Swift 6 entirely) - - name: Select Xcode 15.4 - run: sudo xcode-select -s /Applications/Xcode_15.4.app/Contents/Developer - - name: Confirm Xcode version run: | xcodebuild -version @@ -41,112 +37,153 @@ jobs: - name: Expo prebuild working-directory: ./mobile - run: npx expo prebuild --platform ios + run: bun expo prebuild --platform ios - # ✅ Install CocoaPods cleanly - - name: Install CocoaPods - run: sudo gem install cocoapods + - name: Setup Sherpa Onnx (optional) + working-directory: ./mobile + run: | + chmod +x scripts/setup-sherpa-onnx-optional.sh + ./scripts/setup-sherpa-onnx-optional.sh || true - # ✅ Apply SAFE Podfile patch (no duplication) - - name: Patch Podfile (Swift 5 fix) + - name: Patch node_modules source files + working-directory: ./mobile + run: | + # ExpoModulesCore: remove SE-0431 isolated conformance syntax (Swift 6 only) + 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 in the iOS SDK + TRACKS="node_modules/expo-video/ios/Records/Tracks.swift" + sed -i '' 's/assetVariant\.url/nil/g' "$TRACKS" + echo "ExpoVideo Tracks.swift patched" + + - name: Patch Podfile working-directory: ./mobile/ios run: | - ruby <<'RUBY' - file = 'Podfile' - content = File.read(file) - - unless content.include?("SWIFT_VERSION") - injection = <<~EOS - - post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['SWIFT_VERSION'] = '5.0' - config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' - config.build_settings['SWIFT_TREAT_WARNINGS_AS_ERRORS'] = 'NO' - end + 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| + dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if dt && dt.to_f < 16.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' + end + 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 - EOS - - content += injection - File.write(file, content) - puts "✅ Podfile patched" - else - puts "ℹ️ Podfile already patched" - end - RUBY + 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 25 "post_install" Podfile | head -35 - name: Install pods working-directory: ./mobile/ios - run: pod install --repo-update + run: pod install - # ✅ Dynamically detect workspace + scheme - - name: Detect workspace and scheme + - name: List schemes working-directory: ./mobile/ios - run: | - WORKSPACE=$(ls *.xcworkspace | head -1) - echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV - - SCHEME=$(xcodebuild -workspace "$WORKSPACE" -list | awk '/Schemes:/ {getline; print $1}') - echo "SCHEME=$SCHEME" >> $GITHUB_ENV - - echo "Using workspace: $WORKSPACE" - echo "Using scheme: $SCHEME" + 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 "$WORKSPACE" \ - -scheme "$SCHEME" \ + -workspace Mentra.xcworkspace \ + -scheme Mentra \ -configuration Release \ -destination 'generic/platform=iOS' \ - -archivePath "$GITHUB_WORKSPACE/App.xcarchive" \ + -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="" \ + IPHONEOS_DEPLOYMENT_TARGET=16.0 \ 2>&1 | tee /tmp/xcodebuild.log - EXIT_CODE=${PIPESTATUS[0]} + BUILD_RESULT=${PIPESTATUS[0]} - if [ $EXIT_CODE -ne 0 ]; then - echo "❌ Build failed" - grep ": error:" /tmp/xcodebuild.log | head -50 - exit $EXIT_CODE + 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: Extract .app + - name: Verify archive and find .app run: | - APP_PATH=$(find $GITHUB_WORKSPACE -name "*.app" -type d | head -1) - + APP_PATH=$(find "$GITHUB_WORKSPACE" -name "Mentra.app" -type d | head -1) if [ -z "$APP_PATH" ]; then - echo "❌ .app not found" + 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 - echo "Found app: $APP_PATH" - # ✅ SideStore-compatible IPA packaging - name: Package IPA run: | - mkdir Payload - cp -r "$APP_PATH" Payload/App.app - zip -r App.ipa Payload - ls -lh App.ipa + 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: SideStore-IPA - path: App.ipa + 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 logs + - name: Upload build log if: always() uses: actions/upload-artifact@v4 with: - name: logs + name: xcodebuild-log path: /tmp/xcodebuild.log + retention-days: 5 From 13e773fa452db9c0667cc8193284c307ed8ee399 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:52:43 +1200 Subject: [PATCH 51/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index e340426678..9aa3815676 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -57,10 +57,11 @@ jobs: sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" echo "ExpoModulesCore patches applied" - # ExpoVideo: AVAssetVariant has no 'url' property in the iOS SDK + # ExpoVideo: AVAssetVariant has no 'url' property — replace with typed nil TRACKS="node_modules/expo-video/ios/Records/Tracks.swift" - sed -i '' 's/assetVariant\.url/nil/g' "$TRACKS" + sed -i '' 's/let trackUrl = assetVariant\.url/let trackUrl: URL? = nil/g' "$TRACKS" echo "ExpoVideo Tracks.swift patched" + grep -n "trackUrl" "$TRACKS" - name: Patch Podfile working-directory: ./mobile/ios From e651206464bcd6ed2a608c2f2c1c58fe56a3ecf2 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:33:28 +1200 Subject: [PATCH 52/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 9aa3815676..14ecc9655c 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -57,9 +57,10 @@ jobs: sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" echo "ExpoModulesCore patches applied" - # ExpoVideo: AVAssetVariant has no 'url' property — replace with typed nil + # ExpoVideo: AVAssetVariant has no 'url' — set to nil and fallback to mainUrl 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" grep -n "trackUrl" "$TRACKS" From ab956dc30d7b5a24adcc3168b901ea464c999090 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:02:33 +1200 Subject: [PATCH 53/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 14ecc9655c..f8e8c41271 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -62,7 +62,38 @@ jobs: 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" - grep -n "trackUrl" "$TRACKS" + + # expo-router and expo-notifications use iOS 26 APIs not in iOS 18.5 SDK + python3 << 'PYEOF' + def comment_lines(filepath, line_numbers): + with open(filepath, 'r') as f: + lines = f.readlines() + for n in line_numbers: + idx = n - 1 + if idx < len(lines): + lines[idx] = '// [CI patched - iOS 26 API] ' + lines[idx] + with open(filepath, 'w') as f: + f.writelines(lines) + print(f"Patched {filepath} lines {line_numbers}") + + comment_lines( + 'node_modules/expo-router/ios/Toolbar/RouterToolbarHostView.swift', + [85] + ) + comment_lines( + 'node_modules/expo-router/ios/Toolbar/RouterToolbarItemView.swift', + [116, 164, 165, 175, 195, 197] + ) + comment_lines( + 'node_modules/expo-router/ios/Toolbar/RouterToolbarModule.swift', + [98] + ) + comment_lines( + 'node_modules/expo-notifications/ios/ExpoNotifications/Notifications/DateComponentsSerializer.swift', + [9] + ) + PYEOF + echo "expo-router and expo-notifications patches applied" - name: Patch Podfile working-directory: ./mobile/ios From 29e5276bf2fb29f7e5268920789e9c85bc5482fa Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:27:25 +1200 Subject: [PATCH 54/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 52 ++++++++++---------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index f8e8c41271..354ed57ebb 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -57,43 +57,31 @@ jobs: sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" echo "ExpoModulesCore patches applied" - # ExpoVideo: AVAssetVariant has no 'url' — set to nil and fallback to mainUrl + # ExpoVideo: AVAssetVariant has no 'url' 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" - # expo-router and expo-notifications use iOS 26 APIs not in iOS 18.5 SDK - python3 << 'PYEOF' - def comment_lines(filepath, line_numbers): - with open(filepath, 'r') as f: - lines = f.readlines() - for n in line_numbers: - idx = n - 1 - if idx < len(lines): - lines[idx] = '// [CI patched - iOS 26 API] ' + lines[idx] - with open(filepath, 'w') as f: - f.writelines(lines) - print(f"Patched {filepath} lines {line_numbers}") - - comment_lines( - 'node_modules/expo-router/ios/Toolbar/RouterToolbarHostView.swift', - [85] - ) - comment_lines( - 'node_modules/expo-router/ios/Toolbar/RouterToolbarItemView.swift', - [116, 164, 165, 175, 195, 197] - ) - comment_lines( - 'node_modules/expo-router/ios/Toolbar/RouterToolbarModule.swift', - [98] - ) - comment_lines( - 'node_modules/expo-notifications/ios/ExpoNotifications/Notifications/DateComponentsSerializer.swift', - [9] - ) - PYEOF - echo "expo-router and expo-notifications patches applied" + # expo-router Toolbar: comment out all iOS 26 API references by content pattern + for FILE in \ + "node_modules/expo-router/ios/Toolbar/RouterToolbarHostView.swift" \ + "node_modules/expo-router/ios/Toolbar/RouterToolbarItemView.swift" \ + "node_modules/expo-router/ios/Toolbar/RouterToolbarModule.swift"; do + sed -i '' 's/.*hidesSharedBackground.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + sed -i '' 's/.*sharesBackground.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + sed -i '' 's/.*searchBarPlacementBarButtonItem.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + sed -i '' 's/.*UIBarButtonItem\.Badge.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + sed -i '' 's/.*\.badge.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + sed -i '' 's/.*\.prominent.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + sed -i '' 's/.*badge =.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" + echo "Patched $FILE" + done + + # expo-notifications: DateComponents.isRepeatedDay is iOS 26 API + NOTIFY="node_modules/expo-notifications/ios/ExpoNotifications/Notifications/DateComponentsSerializer.swift" + sed -i '' 's/.*isRepeatedDay.*/\/\/ [CI] iOS 26 API removed/g' "$NOTIFY" + echo "expo-notifications patched" - name: Patch Podfile working-directory: ./mobile/ios From 931b52ec6ad06baa73e9274420e31eeb835c648a Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:36:01 +1200 Subject: [PATCH 55/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 33 ++++++++++++-------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 354ed57ebb..18e97447d6 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -57,31 +57,28 @@ jobs: sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" echo "ExpoModulesCore patches applied" - # ExpoVideo: AVAssetVariant has no 'url' + # ExpoVideo: AVAssetVariant has no 'url' — set to nil and fallback to mainUrl 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" - # expo-router Toolbar: comment out all iOS 26 API references by content pattern - for FILE in \ - "node_modules/expo-router/ios/Toolbar/RouterToolbarHostView.swift" \ - "node_modules/expo-router/ios/Toolbar/RouterToolbarItemView.swift" \ - "node_modules/expo-router/ios/Toolbar/RouterToolbarModule.swift"; do - sed -i '' 's/.*hidesSharedBackground.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - sed -i '' 's/.*sharesBackground.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - sed -i '' 's/.*searchBarPlacementBarButtonItem.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - sed -i '' 's/.*UIBarButtonItem\.Badge.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - sed -i '' 's/.*\.badge.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - sed -i '' 's/.*\.prominent.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - sed -i '' 's/.*badge =.*/\/\/ [CI] iOS 26 API removed/g' "$FILE" - echo "Patched $FILE" + # expo-router Toolbar: iOS 26 APIs not in iOS 18.5 SDK — delete by pattern + TOOLBAR_DIR="node_modules/expo-router/ios/Toolbar" + for f in "$TOOLBAR_DIR"/*.swift; do + sed -i '' '/hidesSharedBackground/d' "$f" + sed -i '' '/sharesBackground/d' "$f" + sed -i '' '/searchBarPlacementBarButtonItem/d' "$f" + sed -i '' '/UIBarButtonItem\.Badge/d' "$f" + sed -i '' '/\.prominent/d' "$f" + sed -i '' '/\.badge/d' "$f" + echo "Patched: $f" done - # expo-notifications: DateComponents.isRepeatedDay is iOS 26 API - NOTIFY="node_modules/expo-notifications/ios/ExpoNotifications/Notifications/DateComponentsSerializer.swift" - sed -i '' 's/.*isRepeatedDay.*/\/\/ [CI] iOS 26 API removed/g' "$NOTIFY" - echo "expo-notifications patched" + # expo-notifications: DateComponents.isRepeatedDay not in iOS 18.5 SDK + NOTIF="node_modules/expo-notifications/ios/ExpoNotifications/Notifications/DateComponentsSerializer.swift" + sed -i '' '/isRepeatedDay/d' "$NOTIF" + echo "All patches applied" - name: Patch Podfile working-directory: ./mobile/ios From 519f7b9632ddde204209f74370fc00a9e515e309 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:57:15 +1200 Subject: [PATCH 56/73] Update ios-unsigned-ipa.yml --- .github/workflows/ios-unsigned-ipa.yml | 31 +++++--------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ios-unsigned-ipa.yml b/.github/workflows/ios-unsigned-ipa.yml index 18e97447d6..d2d7528d1d 100644 --- a/.github/workflows/ios-unsigned-ipa.yml +++ b/.github/workflows/ios-unsigned-ipa.yml @@ -14,8 +14,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Confirm Xcode version + - name: Select Xcode 26.2 run: | + sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer xcodebuild -version swift --version @@ -48,7 +49,7 @@ jobs: - name: Patch node_modules source files working-directory: ./mobile run: | - # ExpoModulesCore: remove SE-0431 isolated conformance syntax (Swift 6 only) + # 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" @@ -57,29 +58,12 @@ jobs: sed -i '' 's/extension UIView: @MainActor AnyArgument/extension UIView: AnyArgument/g' "$FILE3" echo "ExpoModulesCore patches applied" - # ExpoVideo: AVAssetVariant has no 'url' — set to nil and fallback to mainUrl + # 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" - # expo-router Toolbar: iOS 26 APIs not in iOS 18.5 SDK — delete by pattern - TOOLBAR_DIR="node_modules/expo-router/ios/Toolbar" - for f in "$TOOLBAR_DIR"/*.swift; do - sed -i '' '/hidesSharedBackground/d' "$f" - sed -i '' '/sharesBackground/d' "$f" - sed -i '' '/searchBarPlacementBarButtonItem/d' "$f" - sed -i '' '/UIBarButtonItem\.Badge/d' "$f" - sed -i '' '/\.prominent/d' "$f" - sed -i '' '/\.badge/d' "$f" - echo "Patched: $f" - done - - # expo-notifications: DateComponents.isRepeatedDay not in iOS 18.5 SDK - NOTIF="node_modules/expo-notifications/ios/ExpoNotifications/Notifications/DateComponentsSerializer.swift" - sed -i '' '/isRepeatedDay/d' "$NOTIF" - echo "All patches applied" - - name: Patch Podfile working-directory: ./mobile/ios run: | @@ -90,10 +74,6 @@ jobs: injection = """ installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - dt = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] - if dt && dt.to_f < 16.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' - end if target.name == 'ExpoModulesCore' config.build_settings['SWIFT_VERSION'] = '5' config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal' @@ -119,7 +99,7 @@ jobs: - name: Show patched post_install working-directory: ./mobile/ios - run: grep -A 25 "post_install" Podfile | head -35 + run: grep -A 20 "post_install" Podfile | head -30 - name: Install pods working-directory: ./mobile/ios @@ -148,7 +128,6 @@ jobs: CODE_SIGNING_ALLOWED=NO \ AD_HOC_CODE_SIGNING_ALLOWED=YES \ DEVELOPMENT_TEAM="" \ - IPHONEOS_DEPLOYMENT_TARGET=16.0 \ 2>&1 | tee /tmp/xcodebuild.log BUILD_RESULT=${PIPESTATUS[0]} From afd2d2927a3eef8ba00a1a6ea359e2bb400bef8f Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:01:44 +1200 Subject: [PATCH 57/73] Update select-glasses-model.tsx --- mobile/src/app/pairing/select-glasses-model.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/mobile/src/app/pairing/select-glasses-model.tsx b/mobile/src/app/pairing/select-glasses-model.tsx index 010768274c..92f4848a62 100644 --- a/mobile/src/app/pairing/select-glasses-model.tsx +++ b/mobile/src/app/pairing/select-glasses-model.tsx @@ -17,14 +17,11 @@ import {SETTINGS, useSetting} from "@/stores/settings" import {getGlassesImage} from "@/utils/getGlassesImage" import GlassView from "@/components/ui/GlassView" -// import {useLocalSearchParams} from "expo-router" - export default function SelectGlassesModelScreen() { const {theme} = useAppTheme() const {push, goBack} = useNavigationStore.getState() const [superMode] = useSetting(SETTINGS.super_mode.key) - // when this screen is focused, forget any glasses that may be paired: useFocusEffect( useCallback(() => { CoreModule.forget() @@ -32,7 +29,6 @@ export default function SelectGlassesModelScreen() { }, []), ) - // Get logo component for manufacturer const getManufacturerLogo = (deviceModel: string) => { switch (deviceModel) { case DeviceTypes.G1: @@ -49,32 +45,27 @@ export default function SelectGlassesModelScreen() { } } - // Glasses models that should only be visible in super mode. const SUPER_MODE_ONLY_MODELS = new Set([DeviceTypes.NEX]) - // Platform-specific glasses options const glassesOptions = Platform.OS === "ios" ? [ - // {deviceModel: DeviceTypes.SIMULATED, key: DeviceTypes.SIMULATED}, {deviceModel: DeviceTypes.G1, key: "evenrealities_g1"}, {deviceModel: DeviceTypes.G2, key: "evenrealities_g2"}, {deviceModel: DeviceTypes.LIVE, key: "mentra_live"}, {deviceModel: DeviceTypes.MACH1, key: "mentra_mach1"}, {deviceModel: DeviceTypes.Z100, key: "vuzix-z100"}, {deviceModel: DeviceTypes.NEX, key: "mentra_nex"}, - //{deviceModel: "Brilliant Labs Frame", key: "frame"}, + {deviceModel: DeviceTypes.INMO_GO2, key: "inmo_go2"}, ] : [ - // Android: - // {deviceModel: DeviceTypes.SIMULATED, key: DeviceTypes.SIMULATED}, {deviceModel: DeviceTypes.G1, key: "evenrealities_g1"}, {deviceModel: DeviceTypes.G2, key: "evenrealities_g2"}, {deviceModel: DeviceTypes.LIVE, key: "mentra_live"}, {deviceModel: DeviceTypes.MACH1, key: "mentra_mach1"}, {deviceModel: DeviceTypes.Z100, key: "vuzix-z100"}, {deviceModel: DeviceTypes.NEX, key: "mentra_nex"}, - // {deviceModel: "Brilliant Labs Frame", key: "frame"}, + {deviceModel: DeviceTypes.INMO_GO2, key: "inmo_go2"}, ] const triggerGlassesPairingGuide = async (deviceModel: string) => { From 143eb8e0a6a8b39e9276f7b52d0fdae561998184 Mon Sep 17 00:00:00 2001 From: dionma2020 <131766208+dionma2020@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:38:26 +1200 Subject: [PATCH 58/73] Update prep.tsx --- mobile/src/app/pairing/prep.tsx | 191 +++++++++----------------------- 1 file changed, 54 insertions(+), 137 deletions(-) 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() {