diff --git a/asg_client/AGENTS.md b/asg_client/AGENTS.md index 9e62d3970f..331c77f0e9 100644 --- a/asg_client/AGENTS.md +++ b/asg_client/AGENTS.md @@ -158,7 +158,7 @@ asg_client/ ### Architecture -- **Dependency Injection**: Manual factories under `io/*/core/*Factory.java` and `service/utils/DeviceProfile`. No Dagger/Hilt today (`/di` only has `AppModule.java`). +- **Dependency Injection**: Hilt is used for the service layer (`AsgClientService` and the `di/hilt/` modules). Manual factories remain under `io/*/core/*Factory.java` and `service/utils/DeviceProfile` for device-detection paths. - **Error Reporting**: Use Sentry via reporting package - **Logging**: Use Android Logcat with appropriate tags - **Services**: Follow Android foreground service best practices diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/AsgClientEntryPoint.java b/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/AsgClientEntryPoint.java index d7159faf81..31dd972b1f 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/AsgClientEntryPoint.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/AsgClientEntryPoint.java @@ -1,8 +1,8 @@ package com.mentra.asg_client.di.hilt; -import com.mentra.asg_client.io.bes.BesOtaRegistry; import com.mentra.asg_client.io.file.core.FileManager; import com.mentra.asg_client.io.ota.helpers.OtaHelper; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; import dagger.hilt.EntryPoint; import dagger.hilt.InstallIn; import dagger.hilt.components.SingletonComponent; @@ -14,5 +14,5 @@ public interface AsgClientEntryPoint { OtaHelper otaHelper(); - BesOtaRegistry besOtaRegistry(); + IBesOtaRegistry besOtaRegistry(); } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/OtaModule.java b/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/OtaModule.java index c5f39ac1b2..9a1fa12ca7 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/OtaModule.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/OtaModule.java @@ -3,6 +3,7 @@ import android.content.Context; import com.mentra.asg_client.io.bes.BesOtaRegistry; import com.mentra.asg_client.io.ota.helpers.OtaHelper; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; import dagger.Module; import dagger.Provides; import dagger.hilt.InstallIn; @@ -14,10 +15,17 @@ @InstallIn(SingletonComponent.class) public class OtaModule { + /** Binds the core registry interface to the vendor singleton implementation. */ + @Provides + @Singleton + static IBesOtaRegistry provideBesOtaRegistry(BesOtaRegistry impl) { + return impl; + } + @Provides @Singleton static OtaHelper provideOtaHelper( - @ApplicationContext Context context, BesOtaRegistry besOtaRegistry) { + @ApplicationContext Context context, IBesOtaRegistry besOtaRegistry) { return new OtaHelper(context, besOtaRegistry); } } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/TransportModule.java b/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/TransportModule.java new file mode 100644 index 0000000000..1eabed6baf --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/di/hilt/TransportModule.java @@ -0,0 +1,52 @@ +package com.mentra.asg_client.di.hilt; + +import android.content.Context; +import com.mentra.asg_client.io.bluetooth.core.BluetoothManagerFactory; +import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport; +import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.K900ProtocolStrategy; +import com.mentra.asg_client.io.network.core.NetworkManagerFactory; +import com.mentra.asg_client.io.network.interfaces.INetworkManager; +import com.mentra.asg_client.service.core.processors.CommandProtocolDetector; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.qualifiers.ApplicationContext; +import dagger.hilt.components.SingletonComponent; +import dagger.multibindings.IntoSet; + +/** + * Vendor wiring for the companion transport layer. Binds device-specific implementations (selected + * via the existing factories) to the core transport interfaces, and contributes vendor protocol + * detection strategies to the core {@link CommandProtocolDetector}. + * + *
The transport and network providers are intentionally UNSCOPED: {@code + * AsgClientServiceManager.cleanup()} shuts the transport down when the service is destroyed, so a + * singleton binding would re-inject a dead instance on service restart. Unscoped providers match + * the previous fresh-instance-per-service-creation factory behavior. + */ +@Module +@InstallIn(SingletonComponent.class) +public class TransportModule { + + /** + * Mentra Live MCU wire-format detection. Registered into the core protocol detector at + * runtime so core never imports vendor classes. + */ + @Provides + @IntoSet + static CommandProtocolDetector.ProtocolDetectionStrategy provideK900ProtocolStrategy() { + return new K900ProtocolStrategy(); + } + + /** Device-appropriate companion transport (K900 UART bridge or standard BLE). */ + @Provides + static ICompanionTransport provideCompanionTransport(@ApplicationContext Context context) { + return BluetoothManagerFactory.getBluetoothManager(context); + } + + /** Device-appropriate network manager (K900, system-privileged, or fallback). */ + @Provides + static INetworkManager provideNetworkManager(@ApplicationContext Context context) { + return NetworkManagerFactory.getNetworkManager(context); + } +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaManager.java index 0946cd1518..1a1942f394 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaManager.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaManager.java @@ -7,6 +7,7 @@ import com.mentra.asg_client.io.bes.util.BesOtaUtil; import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.SerialPortBridge; import com.mentra.asg_client.io.bluetooth.utils.ByteUtil; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; import com.mentra.asg_client.service.core.handlers.K900CommandHandler; import com.mentra.asg_client.utils.WakeLockManager; import java.io.File; @@ -19,7 +20,7 @@ * Manages BES2700 firmware OTA updates Handles file loading, packet transmission, state tracking, * and protocol state machine */ -public class BesOtaManager implements BesOtaUartListener, BesOtaCommandListener { +public class BesOtaManager implements IBesOtaController, BesOtaUartListener, BesOtaCommandListener { private static final String TAG = "BesOtaManager"; // Static flag to track if BES OTA is in progress @@ -64,12 +65,21 @@ public BesOtaManager( this.k900CommandHandler = k900CommandHandler; } + /** + * @return true if a BES OTA update is currently in progress + */ + @Override + public boolean isBesOtaInProgress() { + return isBesOtaInProgress; + } + /** * Get current firmware version from BES device * * @return byte array with [major, minor, patch, build] or null if not available */ - public static byte[] getCurrentFirmwareVersion() { + @Override + public byte[] getCurrentFirmwareVersion() { return sCurrentFirmwareVersion; } @@ -80,7 +90,8 @@ public static byte[] getCurrentFirmwareVersion() { * @param versionCode Server version code * @return byte array [major, minor, patch, build] */ - public static byte[] parseServerVersionCode(long versionCode) { + @Override + public byte[] parseServerVersionCode(long versionCode) { int major = (int) (versionCode / 1000000); int minor = (int) ((versionCode / 1000) % 1000); int patch = (int) (versionCode % 1000); @@ -96,7 +107,8 @@ public static byte[] parseServerVersionCode(long versionCode) { * @param v2 Second version * @return true if v1 is newer than v2, false otherwise */ - public static boolean isNewerVersion(byte[] v1, byte[] v2) { + @Override + public boolean isNewerVersion(byte[] v1, byte[] v2) { if (v1 == null || v2 == null || v1.length < 4 || v2.length < 4) { return false; } @@ -233,6 +245,7 @@ public boolean init(String filePath) { * @param filePath Path to firmware .bin file * @return true if started successfully */ + @Override public boolean startFirmwareUpdate(String filePath) { Log.i(TAG, "startFirmwareUpdate: " + filePath); @@ -333,6 +346,7 @@ private void cleanup() { * Called when BES chip grants OTA authorization This is the trigger to actually start the OTA * protocol */ + @Override public void onAuthorizationGranted() { if (!isWaitingForAuthorization) { Log.w(TAG, "Received authorization but not waiting for it"); @@ -372,6 +386,7 @@ public void onAuthorizationGranted() { } /** Called when BES chip denies OTA authorization */ + @Override public void onAuthorizationDenied() { Log.e(TAG, "BES OTA authorization DENIED by BES chip"); if (authTimeoutHandler != null && authTimeoutRunnable != null) { diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaRegistry.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaRegistry.java index 4476eb9e7d..b24745d518 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaRegistry.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bes/BesOtaRegistry.java @@ -1,26 +1,33 @@ package com.mentra.asg_client.io.bes; +import androidx.annotation.Nullable; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; import javax.inject.Inject; import javax.inject.Singleton; -/** Holds the live {@link BesOtaManager} after K900 UART transport is ready. */ +/** Holds the live {@link IBesOtaController} after K900 UART transport is ready. */ @Singleton -public class BesOtaRegistry { +public class BesOtaRegistry implements IBesOtaRegistry { - private volatile BesOtaManager instance; + private volatile IBesOtaController instance; @Inject public BesOtaRegistry() {} - public void setInstance(BesOtaManager manager) { - this.instance = manager; + @Override + public void setInstance(IBesOtaController controller) { + this.instance = controller; } + @Override public void clear() { this.instance = null; } - public BesOtaManager getInstance() { + @Override + @Nullable + public IBesOtaController getInstance() { return instance; } } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManager.java index 3c7e5fc830..cbea130bdd 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManager.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManager.java @@ -79,4 +79,37 @@ interface SendMessageGate { void removeBluetoothListener(TransportListener listener); void shutdown(); + + /** + * Called when the BLE MTU has been negotiated with the phone. Implementations should adjust + * transport packet sizes accordingly. Default is a no-op for transports without configurable + * packet sizes. + * + * @param mtu the negotiated MTU value in bytes + */ + default void onMtuNegotiated(int mtu) {} + + /** + * Called when the transport connection is reset (e.g. the phone reconnects). Implementations + * should restore default transport configuration. Default is a no-op. + */ + default void onTransportReset() {} + + /** + * Called when the phone confirms a file transfer result. Default is a no-op for transports + * without file-transfer sessions. + * + * @param fileName the name of the file that was transferred + * @param success whether the transfer succeeded + */ + default void onFileTransferConfirmation(String fileName, boolean success) {} + + /** + * Called when the MCU sends a file-transfer ACK frame. Default is a no-op for transports + * without file-transfer sessions. + * + * @param state ACK state code from the MCU + * @param index packet index being acknowledged + */ + default void onFileTransferAck(int state, int index) {} } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java index 91ed9a0b04..470d91e8d3 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java @@ -1194,6 +1194,37 @@ private void checkFilePacketAck(int packetIndex) { } } + // ======================================== + // ICompanionTransport transport-level hooks + // ======================================== + + @Override + public void onMtuNegotiated(int mtu) { + BesWireFormat.setFilePackSizeFromMtu(mtu); + Log.i( + TAG, + "📦 MTU negotiated (" + + mtu + + ") - file pack size now " + + BesWireFormat.getFilePackSize()); + } + + @Override + public void onTransportReset() { + BesWireFormat.resetFilePackSize(); + Log.i(TAG, "📦 Transport reset - file pack size restored to default"); + } + + @Override + public void onFileTransferConfirmation(String fileName, boolean success) { + handlePhoneConfirmation(fileName, success); + } + + @Override + public void onFileTransferAck(int state, int index) { + handleFileTransferAck(state, index); + } + /** * Handle file transfer acknowledgment Made public so K900CommandHandler can call it when ACK is * received as JSON diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/interfaces/RgbLedConstants.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/interfaces/RgbLedConstants.java new file mode 100644 index 0000000000..33cd772add --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/hardware/interfaces/RgbLedConstants.java @@ -0,0 +1,21 @@ +package com.mentra.asg_client.io.hardware.interfaces; + +/** + * Hardware-agnostic RGB LED index and brightness constants. + * + *
These values define the wire contract between the phone and the glasses for RGB LED commands
+ * (LED index 0-4 plus a default brightness). Vendor-specific controllers (e.g. {@code
+ * K900RgbLedController}) must keep their own constants in sync with these values.
+ */
+public final class RgbLedConstants {
+ private RgbLedConstants() {}
+
+ public static final int LED_RED = 0;
+ public static final int LED_GREEN = 1;
+ public static final int LED_BLUE = 2;
+ public static final int LED_ORANGE = 3;
+ public static final int LED_WHITE = 4;
+
+ /** Default LED brightness (0-255 scale). */
+ public static final int DEFAULT_BRIGHTNESS = 100;
+}
diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java
index 75f686c6d0..6221d345e9 100644
--- a/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java
+++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java
@@ -13,10 +13,10 @@
import com.mentra.asg_client.settings.AsgSettings;
import com.mentra.asg_client.camera.policy.PhotoSizeTier;
import com.mentra.asg_client.camera.lifecycle.PhotoExifMetadataWriter;
-import com.mentra.asg_client.hardware.K900RgbLedController;
import com.mentra.asg_client.io.file.core.FileManager;
import com.mentra.asg_client.io.hardware.core.HardwareManagerFactory;
import com.mentra.asg_client.io.hardware.interfaces.IHardwareManager;
+import com.mentra.asg_client.io.hardware.interfaces.RgbLedConstants;
import com.mentra.asg_client.io.media.interfaces.ServiceCallbackInterface;
import com.mentra.asg_client.io.media.managers.MediaUploadQueueManager;
import com.mentra.asg_client.io.media.upload.MediaUploadService;
@@ -403,7 +403,7 @@ private void flashPrivacyLedForPhoto() {
* brightness)
*/
private void triggerPhotoFlashLed() {
- triggerPhotoFlashLed(K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS);
+ triggerPhotoFlashLed(RgbLedConstants.DEFAULT_BRIGHTNESS);
}
/**
@@ -427,7 +427,7 @@ private void triggerPhotoFlashLed(int brightness) {
/** Trigger solid white LED for video recording duration (default brightness) */
private void triggerVideoRecordingLed() {
- triggerVideoRecordingLed(K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS);
+ triggerVideoRecordingLed(RgbLedConstants.DEFAULT_BRIGHTNESS);
}
/**
diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/README.md b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/README.md
index ced10f15ff..0943f602d7 100644
--- a/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/README.md
+++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/README.md
@@ -252,8 +252,9 @@ if (success) {
### **OTA Helper Operations**
```java
-// Create OTA helper
-OtaHelper otaHelper = new OtaHelper(context);
+// OtaHelper is Hilt-injected; obtain the instance via @Inject or AsgClientEntryPoint.
+// For testing or non-Hilt contexts use the two-arg constructor:
+// OtaHelper otaHelper = new OtaHelper(context, new BesOtaRegistry());
// Start version check
boolean checkStarted = otaHelper.startVersionCheck(context);
diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/helpers/OtaHelper.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/helpers/OtaHelper.java
index 8f3b852049..b26df6cc15 100644
--- a/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/helpers/OtaHelper.java
+++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/helpers/OtaHelper.java
@@ -10,7 +10,6 @@
import android.os.Looper;
import android.util.Log;
import android.content.Intent;
-import dagger.hilt.android.EntryPointAccessors;
import org.json.JSONException;
import org.json.JSONObject;
@@ -19,12 +18,11 @@
import org.greenrobot.eventbus.ThreadMode;
import com.mentra.asg_client.events.BatteryStatusEvent;
import com.mentra.asg_client.di.hilt.AsgClientEntryPoint;
-import com.mentra.asg_client.io.bes.BesOtaManager;
-import com.mentra.asg_client.io.bes.BesOtaRegistry;
import com.mentra.asg_client.io.ota.events.DownloadProgressEvent;
import com.mentra.asg_client.io.ota.events.InstallationProgressEvent;
import com.mentra.asg_client.io.ota.events.MtkOtaProgressEvent;
-
+import com.mentra.asg_client.io.ota.interfaces.IBesOtaController;
+import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
@@ -87,7 +85,6 @@ public interface PhoneConnectionProvider {
private Handler handler;
private Context context;
- private final BesOtaRegistry besOtaRegistry;
// Update order configuration - can be easily modified to change update sequence
@@ -154,38 +151,9 @@ public void setPhoneInitiatedOta(boolean value) {
private String lastOtaPhoneEventStatus;
private String lastOtaPhoneError;
- // ========== Singleton Pattern ==========
-
- private static volatile OtaHelper instance;
-
- /**
- * Get the singleton instance of OtaHelper.
- * Must call initialize(Context) first.
- * @return The OtaHelper instance, or null if not initialized
- */
- public static OtaHelper getInstance() {
- return instance;
- }
-
- /**
- * Initialize the singleton instance.
- * Should be called once during app startup (e.g., from OtaService).
- * @param context Application context
- * @return The OtaHelper instance
- */
- public static synchronized OtaHelper initialize(Context context) {
- if (instance == null) {
- instance = new OtaHelper(context);
- Log.i(TAG, "OtaHelper singleton initialized");
- }
- return instance;
- }
-
- public OtaHelper(Context context) {
- this(context, new BesOtaRegistry());
- }
+ private final IBesOtaRegistry besOtaRegistry;
- public OtaHelper(Context context, BesOtaRegistry besOtaRegistry) {
+ public OtaHelper(Context context, IBesOtaRegistry besOtaRegistry) {
this.besOtaRegistry = besOtaRegistry;
this.context = context.getApplicationContext(); // Use application context to avoid memory leaks
handler = new Handler(Looper.getMainLooper());
@@ -197,6 +165,22 @@ public OtaHelper(Context context, BesOtaRegistry besOtaRegistry) {
Log.i(TAG, "OTA helper initialized - updates only via phone app");
}
+ /**
+ * @return the active BES OTA controller, or null if BES OTA is not initialized (non-K900
+ * devices, or before the transport is ready)
+ */
+ private IBesOtaController getOtaController() {
+ return besOtaRegistry.getInstance();
+ }
+
+ /**
+ * @return true if a BES OTA update is currently in progress; false when no controller exists
+ */
+ private boolean isBesOtaInProgress() {
+ IBesOtaController controller = getOtaController();
+ return controller != null && controller.isBesOtaInProgress();
+ }
+
public void cleanup() {
if (handler != null) {
handler.removeCallbacksAndMessages(null);
@@ -330,8 +314,6 @@ private void deleteFileIfExists(String path, String label) {
Log.w(TAG, "Failed deleting " + label + ": " + path);
}
}
-
- // Wakelock timeout for OTA process (10 minutes)
private static final long OTA_WAKELOCK_TIMEOUT_MS = 600000;
private List The {@link BesOtaManager} is fetched lazily from {@link AsgClientServiceManager} at event time
- * because it is created later in the service lifecycle (during {@code initializeBluetoothManager}),
- * after subscribers are wired.
+ * The {@link IBesOtaController} is fetched lazily from {@link AsgClientServiceManager} at event
+ * time because it is created later in the service lifecycle (during {@code
+ * initializeBluetoothManager}), after subscribers are wired.
*/
public final class BesOtaAuthEventSubscriber implements IPeripheralBus.McuEventListener {
@@ -35,16 +36,19 @@ public void onMcuEvent(McuEvent event) {
Log.i(TAG, "🔧 Received BES OTA authorization response");
Log.d(TAG, "🔧 BES OTA authorization: " + (authorized ? "GRANTED" : "DENIED"));
- // Notify BesOtaManager of authorization result
- BesOtaManager manager = serviceManager != null ? serviceManager.getBesOtaManager() : null;
- if (manager != null) {
+ // Notify the BES OTA controller of the authorization result
+ IBesOtaController controller =
+ serviceManager != null ? serviceManager.getBesOtaManager() : null;
+ if (controller != null) {
if (authorized) {
- manager.onAuthorizationGranted();
+ controller.onAuthorizationGranted();
} else {
- manager.onAuthorizationDenied();
+ controller.onAuthorizationDenied();
}
} else {
- Log.e(TAG, "❌ BesOtaManager not available - cannot process authorization response");
+ Log.e(
+ TAG,
+ "❌ BES OTA controller not available - cannot process authorization response");
}
}
}
diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriber.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriber.java
index ddbdc6e860..1f6d3a78a9 100644
--- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriber.java
+++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriber.java
@@ -1,15 +1,15 @@
package com.mentra.asg_client.service.core.handlers.subscribers;
import android.util.Log;
-import com.mentra.asg_client.io.bluetooth.managers.K900BluetoothManager;
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
import com.mentra.asg_client.io.peripheral.IPeripheralBus;
import com.mentra.asg_client.io.peripheral.events.FileTransferAckEvent;
import com.mentra.asg_client.io.peripheral.events.McuEvent;
import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
/**
- * Reacts to {@link FileTransferAckEvent}s by forwarding the ACK to the {@link
- * K900BluetoothManager}'s error-queue processing. Moved verbatim from {@code
+ * Reacts to {@link FileTransferAckEvent}s by forwarding the ACK to the active {@link
+ * ICompanionTransport}'s error-queue processing. Moved verbatim from {@code
* K900CommandHandler.handleFileTransferAck}.
*/
public final class FileTransferAckEventSubscriber implements IPeripheralBus.McuEventListener {
@@ -36,11 +36,10 @@ public void onMcuEvent(McuEvent event) {
Log.d(TAG, "📦 File transfer ACK: state=" + state + ", index=" + index);
- // Get K900BluetoothManager and forward the ACK
- K900BluetoothManager bluetoothManager =
- (K900BluetoothManager) serviceManager.getBluetoothManager();
- if (bluetoothManager != null) {
- bluetoothManager.handleFileTransferAck(state, index);
+ // Forward the ACK to the active transport
+ ICompanionTransport transport = serviceManager.getBluetoothManager();
+ if (transport != null) {
+ transport.onFileTransferAck(state, index);
}
}
}
diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java
index b5788e7235..13b825af95 100644
--- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java
+++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java
@@ -38,6 +38,7 @@
import com.mentra.asg_client.service.media.interfaces.IMediaManager;
import com.mentra.asg_client.service.system.interfaces.IConfigurationManager;
import com.mentra.asg_client.service.system.interfaces.IStateManager;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.json.JSONObject;
@@ -90,7 +91,8 @@ public CommandProcessor(
FileManager fileManager,
RgbLedCommandHandler rgbLedCommandHandler,
OtaCommandHandler otaCommandHandler,
- IPeripheralBus peripheralBus) {
+ IPeripheralBus peripheralBus,
+ Set NOTE: this parity test intentionally crosses the core/vendor boundary. It exists only until
+ * the A6 module split (after which the vendor controller moves to its own module and this test
+ * moves or is retired); until then it guarantees neither side drifts.
+ */
+public class RgbLedConstantsTest {
+
+ @Test
+ public void ledIndexes_matchVendorControllerValues() {
+ assertThat(RgbLedConstants.LED_RED).isEqualTo(K900RgbLedController.RGB_LED_RED);
+ assertThat(RgbLedConstants.LED_GREEN).isEqualTo(K900RgbLedController.RGB_LED_GREEN);
+ assertThat(RgbLedConstants.LED_BLUE).isEqualTo(K900RgbLedController.RGB_LED_BLUE);
+ assertThat(RgbLedConstants.LED_ORANGE).isEqualTo(K900RgbLedController.RGB_LED_ORANGE);
+ assertThat(RgbLedConstants.LED_WHITE).isEqualTo(K900RgbLedController.RGB_LED_WHITE);
+ }
+
+ @Test
+ public void defaultBrightness_matchesVendorControllerValue() {
+ assertThat(RgbLedConstants.DEFAULT_BRIGHTNESS)
+ .isEqualTo(K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS);
+ }
+
+ @Test
+ public void ledIndexes_coverContiguousRangeZeroToFour() {
+ assertThat(RgbLedConstants.LED_RED).isEqualTo(0);
+ assertThat(RgbLedConstants.LED_GREEN).isEqualTo(1);
+ assertThat(RgbLedConstants.LED_BLUE).isEqualTo(2);
+ assertThat(RgbLedConstants.LED_ORANGE).isEqualTo(3);
+ assertThat(RgbLedConstants.LED_WHITE).isEqualTo(4);
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/io/ota/helpers/OtaHelperBesGuardTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/io/ota/helpers/OtaHelperBesGuardTest.java
new file mode 100644
index 0000000000..60aace1919
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/io/ota/helpers/OtaHelperBesGuardTest.java
@@ -0,0 +1,107 @@
+package com.mentra.asg_client.io.ota.helpers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import com.mentra.asg_client.io.ota.interfaces.IBesOtaController;
+import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Focused guard-path tests for {@link OtaHelper} verifying null-safe {@link IBesOtaRegistry}
+ * access. The isBesOtaInProgress() guard is exercised by the production paths inside
+ * checkAndUpdateBesFirmware; these tests verify the structural invariants (null controller = no
+ * NPE, registry correctly stores and clears controllers).
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class OtaHelperBesGuardTest {
+
+ /** Simple registry stub whose controller can be swapped per test. */
+ private static final class StubRegistry implements IBesOtaRegistry {
+ private IBesOtaController controller;
+
+ @Override
+ public IBesOtaController getInstance() {
+ return controller;
+ }
+
+ @Override
+ public void setInstance(IBesOtaController controller) {
+ this.controller = controller;
+ }
+
+ @Override
+ public void clear() {
+ this.controller = null;
+ }
+ }
+
+ private OtaHelper otaHelper;
+
+ @After
+ public void tearDown() {
+ if (otaHelper != null) {
+ otaHelper.cleanup();
+ }
+ }
+
+ private OtaHelper newHelper(StubRegistry registry) {
+ Context context = ApplicationProvider.getApplicationContext();
+ otaHelper = new OtaHelper(context, registry);
+ return otaHelper;
+ }
+
+ @Test
+ public void noController_constructionAndCleanup_doesNotThrow() {
+ // Non-K900 reality: the registry never gets a controller; construction and cleanup
+ // must not NPE when getOtaController() always returns null.
+ assertThatCode(
+ () -> {
+ OtaHelper helper = newHelper(new StubRegistry());
+ helper.cleanup();
+ otaHelper = null;
+ })
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ public void activeController_isBesOtaInProgress_delegatesToController() {
+ StubRegistry registry = new StubRegistry();
+ IBesOtaController controller = mock(IBesOtaController.class);
+ when(controller.isBesOtaInProgress()).thenReturn(false);
+ registry.setInstance(controller);
+
+ // Helper constructs and cleans up without NPE when a controller is present.
+ assertThatCode(() -> newHelper(registry).cleanup()).doesNotThrowAnyException();
+ otaHelper = null;
+ }
+
+ @Test
+ public void controllerRemovedAfterConstruction_doesNotThrow() {
+ // Registry is a late-binding seam: the controller can disappear during service shutdown.
+ StubRegistry registry = new StubRegistry();
+ registry.setInstance(mock(IBesOtaController.class));
+ OtaHelper helper = newHelper(registry);
+
+ registry.clear();
+
+ // Any entry point that reaches isBesOtaInProgress() must handle a null controller.
+ assertThatCode(helper::cleanup).doesNotThrowAnyException();
+ }
+
+ @Test
+ public void besOtaRegistry_nullSafeGetInstance_returnsNullWhenUnset() {
+ IBesOtaRegistry registry = new StubRegistry();
+
+ assertThat(registry.getInstance()).isNull();
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandlerTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandlerTest.java
new file mode 100644
index 0000000000..06725b5966
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandlerTest.java
@@ -0,0 +1,79 @@
+package com.mentra.asg_client.service.core.handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Verifies {@link BleConfigCommandHandler} forwards the negotiated MTU to the transport interface
+ * (no {@code BesWireFormat} statics, no vendor casts).
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class BleConfigCommandHandlerTest {
+
+ private AsgClientServiceManager serviceManager;
+ private ICompanionTransport transport;
+ private BleConfigCommandHandler handler;
+
+ @Before
+ public void setUp() {
+ serviceManager = mock(AsgClientServiceManager.class);
+ transport = mock(ICompanionTransport.class);
+ when(serviceManager.getBluetoothManager()).thenReturn(transport);
+ handler = new BleConfigCommandHandler(serviceManager);
+ }
+
+ @Test
+ public void setBleMtu_forwardsMtuToTransport() throws Exception {
+ boolean handled = handler.handleCommand("set_ble_mtu", new JSONObject().put("mtu", 251));
+
+ assertThat(handled).isTrue();
+ verify(transport).onMtuNegotiated(251);
+ }
+
+ @Test
+ public void setBleMtu_zeroMtu_rejectedWithoutTouchingTransport() throws Exception {
+ boolean handled = handler.handleCommand("set_ble_mtu", new JSONObject().put("mtu", 0));
+
+ assertThat(handled).isFalse();
+ verify(transport, never()).onMtuNegotiated(anyInt());
+ }
+
+ @Test
+ public void setBleMtu_missingMtu_rejectedWithoutTouchingTransport() {
+ boolean handled = handler.handleCommand("set_ble_mtu", new JSONObject());
+
+ assertThat(handled).isFalse();
+ verify(transport, never()).onMtuNegotiated(anyInt());
+ }
+
+ @Test
+ public void setBleMtu_transportUnavailable_failsGracefully() throws Exception {
+ when(serviceManager.getBluetoothManager()).thenReturn(null);
+
+ boolean handled = handler.handleCommand("set_ble_mtu", new JSONObject().put("mtu", 251));
+
+ assertThat(handled).isFalse();
+ }
+
+ @Test
+ public void unsupportedCommandType_rejected() {
+ boolean handled = handler.handleCommand("not_a_ble_command", new JSONObject());
+
+ assertThat(handled).isFalse();
+ verify(transport, never()).onMtuNegotiated(anyInt());
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandlerTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandlerTest.java
new file mode 100644
index 0000000000..1885bf0fdf
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandlerTest.java
@@ -0,0 +1,82 @@
+package com.mentra.asg_client.service.core.handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
+import com.mentra.asg_client.service.communication.interfaces.ICommunicationManager;
+import com.mentra.asg_client.service.communication.interfaces.IResponseBuilder;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import com.mentra.asg_client.service.system.interfaces.IStateManager;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Verifies {@link PhoneReadyCommandHandler} resets the transport through the {@link
+ * ICompanionTransport} interface (instead of {@code BesWireFormat} statics) and completes the
+ * glasses_ready handshake.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class PhoneReadyCommandHandlerTest {
+
+ private ICommunicationManager communicationManager;
+ private IStateManager stateManager;
+ private IResponseBuilder responseBuilder;
+ private AsgClientServiceManager serviceManager;
+ private ICompanionTransport transport;
+ private PhoneReadyCommandHandler handler;
+
+ @Before
+ public void setUp() throws Exception {
+ communicationManager = mock(ICommunicationManager.class);
+ stateManager = mock(IStateManager.class);
+ responseBuilder = mock(IResponseBuilder.class);
+ serviceManager = mock(AsgClientServiceManager.class);
+ transport = mock(ICompanionTransport.class);
+
+ when(serviceManager.getBluetoothManager()).thenReturn(transport);
+ when(responseBuilder.buildGlassesReadyResponse())
+ .thenReturn(new JSONObject().put("type", "glasses_ready"));
+ when(communicationManager.sendBluetoothResponse(any(JSONObject.class))).thenReturn(true);
+
+ handler =
+ new PhoneReadyCommandHandler(
+ communicationManager, stateManager, responseBuilder, serviceManager);
+ }
+
+ @Test
+ public void phoneReady_resetsTransportAndSendsGlassesReady() {
+ boolean handled = handler.handleCommand("phone_ready", new JSONObject());
+
+ assertThat(handled).isTrue();
+ verify(transport).onTransportReset();
+ verify(communicationManager).sendBluetoothResponse(any(JSONObject.class));
+ verify(serviceManager).onPhoneReadyHandshakeComplete();
+ }
+
+ @Test
+ public void phoneReady_transportUnavailable_stillSendsGlassesReady() {
+ when(serviceManager.getBluetoothManager()).thenReturn(null);
+
+ boolean handled = handler.handleCommand("phone_ready", new JSONObject());
+
+ assertThat(handled).isTrue();
+ verify(communicationManager).sendBluetoothResponse(any(JSONObject.class));
+ verify(serviceManager).onPhoneReadyHandshakeComplete();
+ }
+
+ @Test
+ public void unsupportedCommandType_rejected() {
+ boolean handled = handler.handleCommand("not_phone_ready", new JSONObject());
+
+ assertThat(handled).isFalse();
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandlerTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandlerTest.java
new file mode 100644
index 0000000000..8fdb534fb0
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandlerTest.java
@@ -0,0 +1,113 @@
+package com.mentra.asg_client.service.core.handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.mentra.asg_client.io.hardware.interfaces.IHardwareManager;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Verifies {@link RgbLedCommandHandler} parses, validates and routes RGB LED commands using core
+ * {@code RgbLedConstants} defaults (no vendor classes involved).
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class RgbLedCommandHandlerTest {
+
+ private AsgClientServiceManager serviceManager;
+ private IHardwareManager hardwareManager;
+ private RgbLedCommandHandler handler;
+
+ @Before
+ public void setUp() {
+ serviceManager = mock(AsgClientServiceManager.class);
+ hardwareManager = mock(IHardwareManager.class);
+ when(hardwareManager.supportsRgbLed()).thenReturn(true);
+ handler = new RgbLedCommandHandler(serviceManager, hardwareManager);
+ }
+
+ @Test
+ public void ledOn_noOptionalFields_usesCoreDefaults() {
+ boolean handled = handler.handleCommand("rgb_led_control_on", new JSONObject());
+
+ assertThat(handled).isTrue();
+ // Defaults: LED_RED(0), 1000ms on, 1000ms off, 1 cycle, DEFAULT_BRIGHTNESS(100)
+ verify(hardwareManager).setRgbLedOn(0, 1000, 1000, 1, 100);
+ }
+
+ @Test
+ public void ledOn_invalidLedIndexTooHigh_rejected() throws Exception {
+ boolean handled =
+ handler.handleCommand("rgb_led_control_on", new JSONObject().put("led", 5));
+
+ assertThat(handled).isFalse();
+ verify(hardwareManager, never())
+ .setRgbLedOn(anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
+ }
+
+ @Test
+ public void ledOn_invalidLedIndexNegative_rejected() throws Exception {
+ boolean handled =
+ handler.handleCommand("rgb_led_control_on", new JSONObject().put("led", -1));
+
+ assertThat(handled).isFalse();
+ verify(hardwareManager, never())
+ .setRgbLedOn(anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
+ }
+
+ @Test
+ public void ledOn_invalidBrightness_rejected() throws Exception {
+ boolean handled =
+ handler.handleCommand(
+ "rgb_led_control_on", new JSONObject().put("brightness", 256));
+
+ assertThat(handled).isFalse();
+ verify(hardwareManager, never())
+ .setRgbLedOn(anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
+ }
+
+ @Test
+ public void photoFlash_noBrightness_usesDefaultBrightness() {
+ boolean handled = handler.handleCommand("rgb_led_photo_flash", new JSONObject());
+
+ assertThat(handled).isTrue();
+ verify(hardwareManager).flashRgbLedWhite(5000, 100);
+ }
+
+ @Test
+ public void videoSolid_noBrightness_usesDefaultBrightness() {
+ boolean handled = handler.handleCommand("rgb_led_video_solid", new JSONObject());
+
+ assertThat(handled).isTrue();
+ verify(hardwareManager).setRgbLedSolidWhite(1800000, 100);
+ }
+
+ @Test
+ public void ledOff_routesToHardwareManager() {
+ boolean handled = handler.handleCommand("rgb_led_control_off", new JSONObject());
+
+ assertThat(handled).isTrue();
+ verify(hardwareManager).setRgbLedOff();
+ }
+
+ @Test
+ public void rgbLedNotSupported_rejectsCommand() {
+ when(hardwareManager.supportsRgbLed()).thenReturn(false);
+
+ boolean handled = handler.handleCommand("rgb_led_control_on", new JSONObject());
+
+ assertThat(handled).isFalse();
+ verify(hardwareManager, never())
+ .setRgbLedOn(anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandlerTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandlerTest.java
new file mode 100644
index 0000000000..3025ce3f39
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandlerTest.java
@@ -0,0 +1,84 @@
+package com.mentra.asg_client.service.core.handlers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Verifies {@link TransferCompleteCommandHandler} forwards phone confirmations through the {@link
+ * ICompanionTransport} interface. Uses a plain transport mock (NOT a K900 type) — the regression
+ * guard for the old {@code (K900BluetoothManager)} cast, which would have thrown {@code
+ * ClassCastException} here.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class TransferCompleteCommandHandlerTest {
+
+ private AsgClientServiceManager serviceManager;
+ private ICompanionTransport transport;
+ private TransferCompleteCommandHandler handler;
+
+ @Before
+ public void setUp() {
+ serviceManager = mock(AsgClientServiceManager.class);
+ transport = mock(ICompanionTransport.class);
+ when(serviceManager.getBluetoothManager()).thenReturn(transport);
+ handler = new TransferCompleteCommandHandler(serviceManager);
+ }
+
+ @Test
+ public void transferComplete_success_forwardedToTransport() throws Exception {
+ boolean handled =
+ handler.handleCommand(
+ "transfer_complete",
+ new JSONObject().put("fileName", "photo.jpg").put("success", true));
+
+ assertThat(handled).isTrue();
+ verify(transport).onFileTransferConfirmation("photo.jpg", true);
+ }
+
+ @Test
+ public void transferComplete_failure_forwardedToTransport() throws Exception {
+ boolean handled =
+ handler.handleCommand(
+ "transfer_complete",
+ new JSONObject().put("fileName", "video.mp4").put("success", false));
+
+ assertThat(handled).isTrue();
+ verify(transport).onFileTransferConfirmation("video.mp4", false);
+ }
+
+ @Test
+ public void transferComplete_missingFileName_rejected() throws Exception {
+ boolean handled =
+ handler.handleCommand("transfer_complete", new JSONObject().put("success", true));
+
+ assertThat(handled).isFalse();
+ verify(transport, never()).onFileTransferConfirmation(anyString(), anyBoolean());
+ }
+
+ @Test
+ public void transferComplete_transportUnavailable_failsGracefully() throws Exception {
+ when(serviceManager.getBluetoothManager()).thenReturn(null);
+
+ boolean handled =
+ handler.handleCommand(
+ "transfer_complete",
+ new JSONObject().put("fileName", "photo.jpg").put("success", true));
+
+ assertThat(handled).isFalse();
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriberTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriberTest.java
index bdb4358263..12298794ab 100644
--- a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriberTest.java
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriberTest.java
@@ -5,7 +5,7 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import com.mentra.asg_client.io.bes.BesOtaManager;
+import com.mentra.asg_client.io.ota.interfaces.IBesOtaController;
import com.mentra.asg_client.io.peripheral.events.BesOtaAuthEvent;
import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
import org.junit.Before;
@@ -14,19 +14,23 @@
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
-/** Verifies {@link BesOtaAuthEventSubscriber} routes the OTA authorization result correctly. */
+/**
+ * Verifies {@link BesOtaAuthEventSubscriber} routes the OTA authorization result correctly. The
+ * controller is mocked as the core {@link IBesOtaController} interface — the subscriber must not
+ * depend on the vendor implementation.
+ */
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 33)
public class BesOtaAuthEventSubscriberTest {
private AsgClientServiceManager serviceManager;
- private BesOtaManager besOtaManager;
+ private IBesOtaController besOtaManager;
private BesOtaAuthEventSubscriber subscriber;
@Before
public void setUp() {
serviceManager = mock(AsgClientServiceManager.class);
- besOtaManager = mock(BesOtaManager.class);
+ besOtaManager = mock(IBesOtaController.class);
when(serviceManager.getBesOtaManager()).thenReturn(besOtaManager);
subscriber = new BesOtaAuthEventSubscriber(serviceManager);
}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthRoutingIntegrationTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthRoutingIntegrationTest.java
new file mode 100644
index 0000000000..77f443d9e5
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthRoutingIntegrationTest.java
@@ -0,0 +1,80 @@
+package com.mentra.asg_client.service.core.handlers.subscribers;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
+import com.mentra.asg_client.io.ota.interfaces.IBesOtaController;
+import com.mentra.asg_client.io.peripheral.IPeripheralBus;
+import com.mentra.asg_client.io.peripheral.SimplePeripheralBus;
+import com.mentra.asg_client.io.peripheral.events.BesOtaAuthEvent;
+import com.mentra.asg_client.io.peripheral.events.FileTransferAckEvent;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Integration test for the event-bus-to-interface seam: a REAL {@link SimplePeripheralBus} with
+ * REAL subscribers, asserting MCU events land on the core {@link IBesOtaController} and {@link
+ * ICompanionTransport} interfaces — the exact boundary the A6 module split cuts.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class BesOtaAuthRoutingIntegrationTest {
+
+ private AsgClientServiceManager serviceManager;
+ private IBesOtaController otaController;
+ private ICompanionTransport transport;
+ private IPeripheralBus bus;
+
+ @Before
+ public void setUp() {
+ serviceManager = mock(AsgClientServiceManager.class);
+ otaController = mock(IBesOtaController.class);
+ transport = mock(ICompanionTransport.class);
+ when(serviceManager.getBesOtaManager()).thenReturn(otaController);
+ when(serviceManager.getBluetoothManager()).thenReturn(transport);
+
+ bus = new SimplePeripheralBus();
+ bus.subscribe(new BesOtaAuthEventSubscriber(serviceManager));
+ bus.subscribe(new FileTransferAckEventSubscriber(serviceManager));
+ }
+
+ @Test
+ public void authGrantedEvent_reachesControllerInterface() {
+ bus.publish(new BesOtaAuthEvent(true));
+
+ verify(otaController).onAuthorizationGranted();
+ verify(otaController, never()).onAuthorizationDenied();
+ }
+
+ @Test
+ public void authDeniedEvent_reachesControllerInterface() {
+ bus.publish(new BesOtaAuthEvent(false));
+
+ verify(otaController).onAuthorizationDenied();
+ verify(otaController, never()).onAuthorizationGranted();
+ }
+
+ @Test
+ public void fileTransferAckEvent_reachesTransportInterface() {
+ bus.publish(new FileTransferAckEvent(1, 12));
+
+ verify(transport).onFileTransferAck(1, 12);
+ }
+
+ @Test
+ public void eventsRouteIndependently_ackDoesNotTouchOtaController() {
+ bus.publish(new FileTransferAckEvent(0, 3));
+
+ verify(otaController, never()).onAuthorizationGranted();
+ verify(otaController, never()).onAuthorizationDenied();
+ verify(transport, never()).onMtuNegotiated(anyInt());
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriberTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriberTest.java
new file mode 100644
index 0000000000..ab1b81ee5a
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/handlers/subscribers/FileTransferAckEventSubscriberTest.java
@@ -0,0 +1,61 @@
+package com.mentra.asg_client.service.core.handlers.subscribers;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
+import com.mentra.asg_client.io.peripheral.events.FileTransferAckEvent;
+import com.mentra.asg_client.io.peripheral.events.ShutdownEvent;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Verifies {@link FileTransferAckEventSubscriber} forwards MCU ACK frames through the {@link
+ * ICompanionTransport} interface. Uses a plain transport mock (NOT a K900 type) — regression guard
+ * for the old {@code (K900BluetoothManager)} cast.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class FileTransferAckEventSubscriberTest {
+
+ private AsgClientServiceManager serviceManager;
+ private ICompanionTransport transport;
+ private FileTransferAckEventSubscriber subscriber;
+
+ @Before
+ public void setUp() {
+ serviceManager = mock(AsgClientServiceManager.class);
+ transport = mock(ICompanionTransport.class);
+ when(serviceManager.getBluetoothManager()).thenReturn(transport);
+ subscriber = new FileTransferAckEventSubscriber(serviceManager);
+ }
+
+ @Test
+ public void fileTransferAck_forwardedToTransport() {
+ subscriber.onMcuEvent(new FileTransferAckEvent(1, 7));
+
+ verify(transport).onFileTransferAck(1, 7);
+ }
+
+ @Test
+ public void otherMcuEvent_ignored() {
+ subscriber.onMcuEvent(new ShutdownEvent());
+
+ verify(transport, never()).onFileTransferAck(anyInt(), anyInt());
+ }
+
+ @Test
+ public void transportUnavailable_doesNotThrow() {
+ when(serviceManager.getBluetoothManager()).thenReturn(null);
+
+ subscriber.onMcuEvent(new FileTransferAckEvent(0, 3));
+ // No exception expected; nothing to forward without a transport.
+ }
+}
diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/processors/CommandProcessorRoutingIntegrationTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/processors/CommandProcessorRoutingIntegrationTest.java
new file mode 100644
index 0000000000..52880e75b1
--- /dev/null
+++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/processors/CommandProcessorRoutingIntegrationTest.java
@@ -0,0 +1,151 @@
+package com.mentra.asg_client.service.core.processors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport;
+import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.K900ProtocolStrategy;
+import com.mentra.asg_client.io.file.core.FileManager;
+import com.mentra.asg_client.io.peripheral.IPeripheralBus;
+import com.mentra.asg_client.io.peripheral.events.FileTransferAckEvent;
+import com.mentra.asg_client.io.peripheral.events.McuEvent;
+import com.mentra.asg_client.service.communication.interfaces.ICommunicationManager;
+import com.mentra.asg_client.service.communication.interfaces.IResponseBuilder;
+import com.mentra.asg_client.service.core.handlers.OtaCommandHandler;
+import com.mentra.asg_client.service.core.handlers.RgbLedCommandHandler;
+import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;
+import com.mentra.asg_client.service.media.interfaces.IMediaManager;
+import com.mentra.asg_client.service.system.interfaces.IConfigurationManager;
+import com.mentra.asg_client.service.system.interfaces.IStateManager;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Set;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * End-to-end JVM integration test for the protocol-strategy injection seam: raw bytes through a
+ * REAL {@link CommandProcessor}, {@code CommandParser}, {@link CommandProtocolDetector} (with the
+ * vendor strategy injected at runtime), {@code K900CommandHandler}, {@code McuEventParser} and out
+ * to the peripheral bus / transport interface. This is the path the A6 module split will cut
+ * across modules.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 33)
+public class CommandProcessorRoutingIntegrationTest {
+
+ private AsgClientServiceManager serviceManager;
+ private ICompanionTransport transport;
+ private IPeripheralBus peripheralBus;
+
+ @Before
+ public void setUp() {
+ serviceManager = mock(AsgClientServiceManager.class);
+ transport = mock(ICompanionTransport.class);
+ peripheralBus = mock(IPeripheralBus.class);
+ when(serviceManager.getBluetoothManager()).thenReturn(transport);
+ }
+
+ private CommandProcessor newProcessor(
+ Set