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 buildStepSequence(JSONObject rootJson, JSONObject apps, Context context) { @@ -474,12 +456,11 @@ public void startVersionCheckWithUrl(Context context, String versionJsonUrl) { + ", lockHeld=" + versionCheckLock.isLocked() + ", isUpdating=" + isUpdating + ", mtkInProgress=" + isMtkOtaInProgress - + ", besInProgress=" + BesOtaManager.isBesOtaInProgress + + ", besInProgress=" + isBesOtaInProgress() + ", versionJsonUrl=" + resolvedVersionJsonUrl); - // // Check battery status before proceeding with OTA update - // if (!isBatterySufficientForUpdates()) { - // Log.w(TAG, "🚨 Battery insufficient for OTA updates - skipping version check"); + // if (!isNetworkAvailable(context)) { + // Log.e(TAG, "No WiFi connection available. Skipping OTA check."); // return; // } @@ -872,7 +853,7 @@ private boolean checkAndUpdateApp(String packageName, JSONObject appInfo, Contex lastApkFailureErrorCode = null; // Check for mutual exclusion - don't start APK update if firmware update in progress - if (BesOtaManager.isBesOtaInProgress) { + if (isBesOtaInProgress()) { Log.w(TAG, "BES firmware update in progress - skipping APK update"); return false; } @@ -1798,7 +1779,7 @@ private boolean checkAndUpdateBesFirmware(JSONObject firmwareInfo, Context conte } // Check if BES OTA already in progress - if (BesOtaManager.isBesOtaInProgress) { + if (isBesOtaInProgress()) { Log.w(TAG, "BES firmware update already in progress"); return false; } @@ -1823,17 +1804,23 @@ private boolean checkAndUpdateBesFirmware(JSONObject firmwareInfo, Context conte return false; } } else { - // Legacy BES schema: versionCode/versionName. + // Legacy BES schema: versionCode/versionName, compared via IBesOtaController. long serverVersion = firmwareInfo.optLong("versionCode", 0); String versionName = firmwareInfo.optString("versionName", "unknown"); Log.i(TAG, "BES firmware available - Version: " + versionName + " (code: " + serverVersion + ")"); - byte[] currentVersion = BesOtaManager.getCurrentFirmwareVersion(); - byte[] serverVersionBytes = BesOtaManager.parseServerVersionCode(serverVersion); + IBesOtaController legacyController = getOtaController(); + if (legacyController == null) { + Log.w(TAG, "BES OTA controller not available - skipping BES firmware update"); + return false; + } + + byte[] currentVersion = legacyController.getCurrentFirmwareVersion(); + byte[] serverVersionBytes = legacyController.parseServerVersionCode(serverVersion); if (currentVersion != null && serverVersionBytes != null) { - boolean isNewer = BesOtaManager.isNewerVersion(serverVersionBytes, currentVersion); + boolean isNewer = legacyController.isNewerVersion(serverVersionBytes, currentVersion); Log.d(TAG, "Current firmware: " + (currentVersion[0] & 0xFF) + "." + (currentVersion[1] & 0xFF) + "." + (currentVersion[2] & 0xFF) + "." + (currentVersion[3] & 0xFF)); Log.d(TAG, "Server firmware: " + (serverVersionBytes[0] & 0xFF) + "." + @@ -1871,7 +1858,7 @@ private boolean checkAndUpdateBesFirmware(JSONObject firmwareInfo, Context conte } Log.i(TAG, "BES firmware ready - starting install phase"); - BesOtaManager manager = besOtaRegistry.getInstance(); + IBesOtaController manager = besOtaRegistry.getInstance(); if (manager != null) { Log.i(TAG, "Starting BES firmware update from: " + OtaConstants.BES_FIRMWARE_PATH); boolean started = manager.startFirmwareUpdate(OtaConstants.BES_FIRMWARE_PATH); @@ -2077,7 +2064,7 @@ private boolean checkAndUpdateMtkFirmware(JSONObject firmwareInfo, Context conte return false; } - if (BesOtaManager.isBesOtaInProgress) { + if (isBesOtaInProgress()) { Log.w(TAG, "BES firmware update in progress - skipping MTK firmware update"); return false; } @@ -2789,8 +2776,13 @@ public static boolean debugInstallMtkFirmware(Context context) { */ public static boolean debugInstallBesFirmware(Context context) { try { + IBesOtaRegistry registry = + dagger.hilt.android.EntryPointAccessors.fromApplication( + context.getApplicationContext(), AsgClientEntryPoint.class) + .besOtaRegistry(); // Check if BES OTA is already in progress - don't interrupt it! - if (BesOtaManager.isBesOtaInProgress) { + IBesOtaController activeController = registry.getInstance(); + if (activeController != null && activeController.isBesOtaInProgress()) { Log.w(TAG, "DEBUG: BES OTA already in progress - skipping to avoid interruption"); return false; } @@ -2806,19 +2798,14 @@ public static boolean debugInstallBesFirmware(Context context) { Log.w(TAG, "⚠️ DEBUG: File size: " + firmwareFile.length() + " bytes"); Log.w(TAG, "⚠️ DEBUG: Skipping all checks - version, mutual exclusion, SHA256"); - BesOtaRegistry registry = - EntryPointAccessors.fromApplication( - context.getApplicationContext(), AsgClientEntryPoint.class) - .besOtaRegistry(); - - // Get BesOtaManager singleton - BesOtaManager manager = registry.getInstance(); + // Get the active BES OTA controller + IBesOtaController manager = registry.getInstance(); if (manager == null) { - Log.e(TAG, "DEBUG: BesOtaManager not available - is this a K900 device?"); + Log.e(TAG, "DEBUG: BES OTA controller not available - is this a K900 device?"); return false; } - Log.i(TAG, "DEBUG: Starting BES firmware update via BesOtaManager"); + Log.i(TAG, "DEBUG: Starting BES firmware update via BES OTA controller"); boolean started = manager.startFirmwareUpdate(OtaConstants.BES_FIRMWARE_PATH); if (started) { diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/interfaces/IBesOtaController.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/interfaces/IBesOtaController.java new file mode 100644 index 0000000000..24ab941178 --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/interfaces/IBesOtaController.java @@ -0,0 +1,55 @@ +package com.mentra.asg_client.io.ota.interfaces; + +import androidx.annotation.Nullable; + +/** + * Operations on the BES coprocessor OTA subsystem. Implemented by the vendor-specific OTA manager + * (e.g. {@code BesOtaManager} on Mentra Live); core code interacts with BES firmware updates only + * through this interface. + */ +public interface IBesOtaController { + + /** + * @return true if a BES OTA update is currently in progress + */ + boolean isBesOtaInProgress(); + + /** + * Get the current firmware version reported by the BES device. + * + * @return version bytes [major, minor, patch, build], or null if not yet known + */ + @Nullable + byte[] getCurrentFirmwareVersion(); + + /** + * Parse a server-supplied version code into the byte format used for comparison. + * + * @param versionCode long version code from the server (XYYYZZZ format) + * @return byte array [major, minor, patch, build] + */ + byte[] parseServerVersionCode(long versionCode); + + /** + * Compare two firmware version byte arrays. + * + * @param v1 candidate version + * @param v2 reference version + * @return true if v1 is newer than v2 + */ + boolean isNewerVersion(byte[] v1, byte[] v2); + + /** + * Begin a firmware update. + * + * @param filePath path to the firmware binary + * @return true if the update was started successfully + */ + boolean startFirmwareUpdate(String filePath); + + /** Called by the MCU event subscriber when BES authorizes the update. */ + void onAuthorizationGranted(); + + /** Called by the MCU event subscriber when BES denies the update. */ + void onAuthorizationDenied(); +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/interfaces/IBesOtaRegistry.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/interfaces/IBesOtaRegistry.java new file mode 100644 index 0000000000..3aeb6307ad --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/ota/interfaces/IBesOtaRegistry.java @@ -0,0 +1,23 @@ +package com.mentra.asg_client.io.ota.interfaces; + +import androidx.annotation.Nullable; + +/** + * Late-binding registry for the BES OTA controller. The controller is created by the vendor wiring + * layer only after the companion transport is ready, so consumers must look it up at call time and + * tolerate a null result (non-K900 devices never set one). + */ +public interface IBesOtaRegistry { + + /** + * @return the active controller, or null if BES OTA has not been initialized + */ + @Nullable + IBesOtaController getInstance(); + + /** Publish the active controller after the vendor transport initializes it. */ + void setInstance(IBesOtaController controller); + + /** Remove the active controller (e.g. during service shutdown). */ + void clear(); +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/receiver/DebugBesOtaReceiver.java b/asg_client/app/src/main/java/com/mentra/asg_client/receiver/DebugBesOtaReceiver.java index 6ddd95ad6a..9645299ce8 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/receiver/DebugBesOtaReceiver.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/receiver/DebugBesOtaReceiver.java @@ -5,7 +5,7 @@ import android.content.Intent; import android.util.Log; import com.mentra.asg_client.di.hilt.AsgClientEntryPoint; -import com.mentra.asg_client.io.bes.BesOtaManager; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; import com.mentra.asg_client.io.ota.utils.OtaConstants; import dagger.hilt.android.EntryPointAccessors; import java.io.File; @@ -45,18 +45,18 @@ public void onReceive(Context context, Intent intent) { Log.i(TAG, "✅ Firmware file found: " + firmwareFile.length() + " bytes"); - // Get BesOtaManager instance - BesOtaManager manager = + // Get the active BES OTA controller + IBesOtaController manager = EntryPointAccessors.fromApplication(context, AsgClientEntryPoint.class) .besOtaRegistry() .getInstance(); if (manager == null) { - Log.e(TAG, "❌ BesOtaManager not initialized - is AsgClientService running?"); + Log.e(TAG, "❌ BES OTA controller not initialized - is AsgClientService running?"); return; } // Check if already in progress - if (BesOtaManager.isBesOtaInProgress) { + if (manager.isBesOtaInProgress()) { Log.w(TAG, "⚠️ BES OTA already in progress - skipping"); return; } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java index c0c976c715..0b253cefde 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/AsgClientService.java @@ -19,21 +19,24 @@ import android.util.Size; import com.dev.api.DevApi; import com.mentra.asg_client.camera.UvcStreamingState; -import com.mentra.asg_client.hardware.K900RgbLedController; -import com.mentra.asg_client.io.bes.BesOtaRegistry; +import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport; import com.mentra.asg_client.io.bluetooth.interfaces.TransportListener; import com.mentra.asg_client.io.file.core.FileManager; 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.core.MediaCaptureService; import com.mentra.asg_client.io.media.interfaces.ServiceCallbackInterface; import com.mentra.asg_client.io.media.managers.MediaUploadQueueManager; +import com.mentra.asg_client.io.network.interfaces.INetworkManager; import com.mentra.asg_client.io.network.interfaces.NetworkStateListener; import com.mentra.asg_client.io.ota.helpers.OtaHelper; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; import com.mentra.asg_client.io.ota.utils.OtaConstants; import com.mentra.asg_client.io.streaming.events.StreamingEvent; import com.mentra.asg_client.logging.BleTraceLogger; import com.mentra.asg_client.service.communication.interfaces.ICommunicationManager; import com.mentra.asg_client.service.core.processors.CommandProcessor; +import com.mentra.asg_client.service.core.processors.CommandProtocolDetector; import com.mentra.asg_client.service.media.interfaces.IMediaManager; import com.mentra.asg_client.service.system.core.SystemControllerFactory; import com.mentra.asg_client.service.system.interfaces.IConfigurationManager; @@ -46,8 +49,10 @@ import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import javax.inject.Inject; +import javax.inject.Provider; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -68,7 +73,23 @@ public class AsgClientService extends Service implements NetworkStateListener, T @Inject FileManager fileManager; @Inject OtaHelper otaHelper; @Inject IHardwareManager hardwareManager; - @Inject BesOtaRegistry besOtaRegistry; + @Inject IBesOtaRegistry besOtaRegistry; + + /** Vendor-supplied protocol detection strategies (e.g. the Mentra Live MCU wire format). */ + @Inject Set protocolStrategies; + + /** + * Provider for the device-appropriate companion transport. Using {@link Provider} defers + * construction until after {@link #ensureForegroundStarted()} so the K900 serial-port thread + * does not open before the foreground notification is posted. + */ + @Inject Provider companionTransportProvider; + + /** + * Provider for the device-appropriate network manager, deferred for the same reason as + * {@link #companionTransportProvider}. + */ + @Inject Provider networkManagerProvider; // --------------------------------------------- // Constants //TODO: Extract all the Constants and Magic Number/Text to AsgConstants @@ -414,8 +435,7 @@ private void applyUvcStreamingLed(boolean streaming) { if (hardwareManager.supportsRgbLed()) { sendRgbLedControlAuthority(true); hardwareManager.setRgbLedSolidWhite( - UVC_STREAMING_LED_DURATION_MS, - K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS); + UVC_STREAMING_LED_DURATION_MS, RgbLedConstants.DEFAULT_BRIGHTNESS); Log.i(TAG, "UVC streaming RGB ring LED on (solid white)"); } else { Log.w(TAG, "RGB LED not supported on this device"); @@ -650,7 +670,14 @@ private void initializeServiceInitializer() { try { serviceInitializer = new ServiceInitializer( - this, fileManager, otaHelper, hardwareManager, besOtaRegistry); + this, + companionTransportProvider.get(), + networkManagerProvider.get(), + fileManager, + otaHelper, + hardwareManager, + besOtaRegistry, + protocolStrategies); Log.d(TAG, "✅ ServiceInitializer created successfully"); // Initialize container diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/ServiceInitializer.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/ServiceInitializer.java index c74495836b..ce69b694ff 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/ServiceInitializer.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/ServiceInitializer.java @@ -2,14 +2,12 @@ import android.util.Log; import androidx.annotation.NonNull; -import com.mentra.asg_client.io.bes.BesOtaRegistry; -import com.mentra.asg_client.io.bluetooth.core.BluetoothManagerFactory; import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport; import com.mentra.asg_client.io.file.core.FileManager; import com.mentra.asg_client.io.hardware.interfaces.IHardwareManager; -import com.mentra.asg_client.io.network.core.NetworkManagerFactory; import com.mentra.asg_client.io.network.interfaces.INetworkManager; import com.mentra.asg_client.io.ota.helpers.OtaHelper; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; import com.mentra.asg_client.io.peripheral.IPeripheralBus; import com.mentra.asg_client.io.peripheral.SimplePeripheralBus; import com.mentra.asg_client.service.communication.interfaces.ICommunicationManager; @@ -32,6 +30,7 @@ import com.mentra.asg_client.service.core.handlers.subscribers.TouchEventSubscriber; import com.mentra.asg_client.service.core.handlers.subscribers.VoiceActivityEventSubscriber; import com.mentra.asg_client.service.core.processors.CommandProcessor; +import com.mentra.asg_client.service.core.processors.CommandProtocolDetector; import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager; import com.mentra.asg_client.service.media.interfaces.IMediaManager; import com.mentra.asg_client.service.media.managers.MediaManager; @@ -42,6 +41,7 @@ import com.mentra.asg_client.service.system.managers.ConfigurationManager; import com.mentra.asg_client.service.system.managers.ServiceLifecycleManager; import com.mentra.asg_client.service.system.managers.StateManager; +import java.util.Set; /** Wires core service components (replaces the former {@link ServiceContainer}). */ public final class ServiceInitializer { @@ -60,17 +60,18 @@ public final class ServiceInitializer { public ServiceInitializer( @NonNull AsgClientService service, + @NonNull ICompanionTransport transport, + @NonNull INetworkManager network, @NonNull FileManager fileManager, @NonNull OtaHelper otaHelper, @NonNull IHardwareManager hardwareManager, - @NonNull BesOtaRegistry besOtaRegistry) { + @NonNull IBesOtaRegistry besOtaRegistry, + @NonNull Set protocolStrategies) { android.content.Context context = service; - // Create transport and network first — both are passed to CommunicationManager and - // AsgClientServiceManager so neither holds a circular reference to the other. - ICompanionTransport transport = BluetoothManagerFactory.getBluetoothManager(context); - INetworkManager network = NetworkManagerFactory.getNetworkManager(context); - + // Transport and network are constructed by the vendor wiring layer and passed in — both + // go to CommunicationManager and AsgClientServiceManager so neither holds a circular + // reference to the other. this.communicationManager = new CommunicationManager(transport, network); this.serviceManager = @@ -124,7 +125,8 @@ public ServiceInitializer( fileManager, rgbLedHandler, otaCommandHandler, - peripheralBus); + peripheralBus, + protocolStrategies); this.lifecycleManager = new ServiceLifecycleManager( diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandler.java index 8c434e29ed..c1a1cb9ede 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleConfigCommandHandler.java @@ -1,18 +1,25 @@ package com.mentra.asg_client.service.core.handlers; import android.util.Log; -import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.BesWireFormat; +import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport; import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; +import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager; import java.util.Set; import org.json.JSONObject; /** - * Handler for BLE configuration commands from the phone. Handles MTU configuration to adjust file - * packet sizes. + * Handler for BLE configuration commands from the phone. Handles MTU configuration so the active + * transport can adjust its file packet sizes. */ public class BleConfigCommandHandler implements ICommandHandler { private static final String TAG = "BleConfigCommandHandler"; + private final AsgClientServiceManager serviceManager; + + public BleConfigCommandHandler(AsgClientServiceManager serviceManager) { + this.serviceManager = serviceManager; + } + @Override public Set getSupportedCommandTypes() { return Set.of("set_ble_mtu"); @@ -39,8 +46,8 @@ public boolean handleCommand(String commandType, JSONObject data) { } /** - * Handle set BLE MTU command from phone. Adjusts file packet size to fit within the negotiated - * MTU. + * Handle set BLE MTU command from phone. Forwards the negotiated MTU to the active transport + * so it can adjust its packet sizes. */ private boolean handleSetBleMtu(JSONObject data) { Log.i( @@ -56,20 +63,15 @@ private boolean handleSetBleMtu(JSONObject data) { return false; } - Log.i(TAG, "📦 ✅ Setting pack size from MTU: " + mtu); - int oldPackSize = BesWireFormat.getFilePackSize(); - BesWireFormat.setFilePackSizeFromMtu(mtu); - int newPackSize = BesWireFormat.getFilePackSize(); + ICompanionTransport transport = + serviceManager != null ? serviceManager.getBluetoothManager() : null; + if (transport == null) { + Log.e(TAG, "❌ Transport not available - cannot apply MTU " + mtu); + return false; + } - // Log the effective pack size for debugging - int totalPacketSize = newPackSize + 32; // 32 bytes protocol overhead - Log.i(TAG, "📦 ========================================="); - Log.i(TAG, "📦 FILE PACK SIZE CHANGED"); - Log.i(TAG, "📦 Old pack size: " + oldPackSize); - Log.i(TAG, "📦 New pack size: " + newPackSize); - Log.i(TAG, "📦 Total packet size: " + totalPacketSize); - Log.i(TAG, "📦 MTU effective: " + (mtu - 3)); - Log.i(TAG, "📦 ========================================="); + Log.i(TAG, "📦 ✅ Forwarding negotiated MTU to transport: " + mtu); + transport.onMtuNegotiated(mtu); return true; } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/K900CommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/K900CommandHandler.java index df24872dc9..72614afa09 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/K900CommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/K900CommandHandler.java @@ -2,7 +2,7 @@ import android.content.Context; import android.util.Log; -import com.mentra.asg_client.io.bes.BesOtaManager; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; import com.mentra.asg_client.io.bes.log.BesLogManager; import com.mentra.asg_client.io.peripheral.IPeripheralBus; import com.mentra.asg_client.io.peripheral.McuEventParser; @@ -386,7 +386,7 @@ public void sendBesOtaAuthorizationRequest() { if (serviceManager == null || serviceManager.getBluetoothManager() == null) { Log.e(TAG, "❌ ServiceManager or Bluetooth manager unavailable"); // Notify BesOtaManager of failure - BesOtaManager manager = serviceManager.getBesOtaManager(); + IBesOtaController manager = serviceManager.getBesOtaManager(); if (manager != null) { manager.onAuthorizationDenied(); } @@ -396,7 +396,7 @@ public void sendBesOtaAuthorizationRequest() { if (!serviceManager.getBluetoothManager().isConnected()) { Log.e(TAG, "❌ Bluetooth not connected; cannot send BES OTA authorization request"); // Notify BesOtaManager of failure - BesOtaManager manager = serviceManager.getBesOtaManager(); + IBesOtaController manager = serviceManager.getBesOtaManager(); if (manager != null) { manager.onAuthorizationDenied(); } @@ -414,7 +414,7 @@ public void sendBesOtaAuthorizationRequest() { } else { Log.e(TAG, "❌ Failed to send BES OTA authorization request"); // Notify BesOtaManager of failure - BesOtaManager manager = serviceManager.getBesOtaManager(); + IBesOtaController manager = serviceManager.getBesOtaManager(); if (manager != null) { manager.onAuthorizationDenied(); } @@ -422,14 +422,14 @@ public void sendBesOtaAuthorizationRequest() { } catch (JSONException e) { Log.e(TAG, "💥 Error creating BES OTA authorization request", e); // Notify BesOtaManager of failure - BesOtaManager manager = serviceManager.getBesOtaManager(); + IBesOtaController manager = serviceManager.getBesOtaManager(); if (manager != null) { manager.onAuthorizationDenied(); } } catch (Exception e) { Log.e(TAG, "💥 Error sending BES OTA authorization request", e); // Notify BesOtaManager of failure - BesOtaManager manager = serviceManager.getBesOtaManager(); + IBesOtaController manager = serviceManager.getBesOtaManager(); if (manager != null) { manager.onAuthorizationDenied(); } diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandler.java index 98c07ca8e8..7c44642f45 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/PhoneReadyCommandHandler.java @@ -3,7 +3,7 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; -import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.BesWireFormat; +import com.mentra.asg_client.io.bluetooth.interfaces.ICompanionTransport; import com.mentra.asg_client.io.network.interfaces.INetworkManager; import com.mentra.asg_client.service.communication.interfaces.ICommunicationManager; import com.mentra.asg_client.service.communication.interfaces.IResponseBuilder; @@ -65,9 +65,13 @@ private boolean handlePhoneReady(JSONObject data) { Log.d(TAG, "📱 Received phone_ready data: " + (data != null ? data.toString() : "null")); try { - // Reset file pack size to default on new connection. + // Reset transport packet sizing to default on new connection. // Phone will send set_ble_mtu command after glasses_ready to set the correct size. - BesWireFormat.resetFilePackSize(); + ICompanionTransport transport = + serviceManager != null ? serviceManager.getBluetoothManager() : null; + if (transport != null) { + transport.onTransportReset(); + } Log.d(TAG, "📱 📱 Received phone_ready message - sending glasses_ready response"); diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandler.java index 0d34dcb9ad..bcd78ad9cf 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/RgbLedCommandHandler.java @@ -1,8 +1,8 @@ package com.mentra.asg_client.service.core.handlers; import android.util.Log; -import com.mentra.asg_client.hardware.K900RgbLedController; import com.mentra.asg_client.io.hardware.interfaces.IHardwareManager; +import com.mentra.asg_client.io.hardware.interfaces.RgbLedConstants; import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager; import java.nio.charset.StandardCharsets; @@ -128,16 +128,14 @@ private boolean handleRgbLedOn(JSONObject data) { try { // Extract parameters with defaults - int led = data.optInt("led", K900RgbLedController.RGB_LED_RED); + int led = data.optInt("led", RgbLedConstants.LED_RED); int ontime = data.optInt("ontime", 1000); int offtime = data.optInt("offtime", 1000); int count = data.optInt("count", 1); - int brightness = - data.optInt("brightness", K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS); + int brightness = data.optInt("brightness", RgbLedConstants.DEFAULT_BRIGHTNESS); // Validate parameters - if (led < K900RgbLedController.RGB_LED_RED - || led > K900RgbLedController.RGB_LED_WHITE) { + if (led < RgbLedConstants.LED_RED || led > RgbLedConstants.LED_WHITE) { Log.e(TAG, "❌ Invalid RGB LED index: " + led + " (must be 0-4)"); sendErrorResponse("Invalid RGB LED index: " + led); return false; @@ -216,8 +214,7 @@ private boolean handlePhotoFlash(JSONObject data) { try { // Extract flash duration and brightness with defaults int duration = data.optInt("duration", 5000); // Default 5 sec flash - int brightness = - data.optInt("brightness", K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS); + int brightness = data.optInt("brightness", RgbLedConstants.DEFAULT_BRIGHTNESS); // Validate brightness if (brightness < 0 || brightness > 255) { @@ -257,8 +254,7 @@ private boolean handleVideoSolid(JSONObject data) { try { // Extract brightness with default - int brightness = - data.optInt("brightness", K900RgbLedController.DEFAULT_RGB_LED_BRIGHTNESS); + int brightness = data.optInt("brightness", RgbLedConstants.DEFAULT_BRIGHTNESS); // Validate brightness if (brightness < 0 || brightness > 255) { diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandler.java index 74d07478cb..11cb9aabfd 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandler.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/TransferCompleteCommandHandler.java @@ -2,7 +2,6 @@ import android.util.Log; -import com.mentra.asg_client.io.bluetooth.managers.K900BluetoothManager; import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager; import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; @@ -61,11 +60,12 @@ private boolean handleTransferComplete(JSONObject data) { return false; } - // Forward to K900BluetoothManager + // Forward to the active transport if (serviceManager != null && serviceManager.getBluetoothManager() != null) { - K900BluetoothManager bluetoothManager = (K900BluetoothManager) serviceManager.getBluetoothManager(); - bluetoothManager.handlePhoneConfirmation(fileName, success); - Log.d(TAG, "✅ Forwarded transfer_complete to K900BluetoothManager"); + serviceManager + .getBluetoothManager() + .onFileTransferConfirmation(fileName, success); + Log.d(TAG, "✅ Forwarded transfer_complete to transport"); return true; } else { Log.e(TAG, "❌ BluetoothManager not available"); diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriber.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriber.java index ee9ba13d53..a4af2aa6cf 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriber.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/subscribers/BesOtaAuthEventSubscriber.java @@ -1,7 +1,7 @@ package com.mentra.asg_client.service.core.handlers.subscribers; import android.util.Log; -import com.mentra.asg_client.io.bes.BesOtaManager; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; import com.mentra.asg_client.io.peripheral.IPeripheralBus; import com.mentra.asg_client.io.peripheral.events.BesOtaAuthEvent; import com.mentra.asg_client.io.peripheral.events.McuEvent; @@ -9,11 +9,12 @@ /** * Reacts to {@link BesOtaAuthEvent}s by forwarding the authorization result to the {@link - * BesOtaManager}. Moved verbatim from {@code K900CommandHandler.handleBesOtaAuthorizationResponse}. + * IBesOtaController}. Moved verbatim from {@code + * K900CommandHandler.handleBesOtaAuthorizationResponse}. * - *

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 extraProtocolStrategies) { Log.d(TAG, "🔧 Initializing CommandProcessor with dependencies"); this.context = context; this.communicationManager = communicationManager; @@ -115,6 +117,15 @@ public CommandProcessor( this.responseSender = new ResponseSender(serviceManager); this.chunkReassembler = new ChunkReassembler(); + // Register vendor-supplied protocol strategies (e.g. the Mentra Live MCU wire format) + // before chunked support so chunked messages keep the highest priority. + if (extraProtocolStrategies != null) { + for (CommandProtocolDetector.ProtocolDetectionStrategy strategy : + extraProtocolStrategies) { + this.protocolDetector.addDetectionStrategy(strategy); + } + } + // Add chunked message support to protocol detector this.protocolDetector.addChunkedMessageSupport(chunkReassembler); Log.d(TAG, "✅ Chunked message support initialized"); @@ -420,7 +431,7 @@ private void initializeCommandHandlers() { new ServiceHeartbeatCommandHandler(serviceManager)); Log.d(TAG, "✅ Registered ServiceHeartbeatCommandHandler"); - commandHandlerRegistry.registerHandler(new BleConfigCommandHandler()); + commandHandlerRegistry.registerHandler(new BleConfigCommandHandler(serviceManager)); Log.d(TAG, "✅ Registered BleConfigCommandHandler"); commandHandlerRegistry.registerHandler( diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetector.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetector.java index 88888f9281..0bcb124664 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetector.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetector.java @@ -2,7 +2,6 @@ import android.util.Log; import androidx.annotation.NonNull; -import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.K900ProtocolStrategy; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -83,16 +82,19 @@ public CommandProtocolDetector() { /** Initialize detection strategies following Open/Closed Principle */ private void initializeDetectionStrategies() { - // Order matters - more specific strategies should come first + // Order matters - more specific strategies should come first. // ChunkedMessageProtocolStrategy needs to be created with a ChunkReassembler - // This will be set via setter method from CommandProcessor + // (added via addChunkedMessageSupport from CommandProcessor). + // Vendor-specific strategies (e.g. the Mentra Live MCU wire format) are registered at + // runtime via addDetectionStrategy by the vendor wiring layer. detectionStrategies.add(new JsonCommandProtocolStrategy()); - detectionStrategies.add(new K900ProtocolStrategy()); detectionStrategies.add(new UnknownProtocolStrategy()); Log.d( TAG, - "✅ Initialized " + detectionStrategies.size() + " protocol detection strategies"); + "✅ Initialized " + + detectionStrategies.size() + + " base protocol detection strategies"); } /** diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/legacy/managers/AsgClientServiceManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/legacy/managers/AsgClientServiceManager.java index 36b9f85659..0633e00dd2 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/legacy/managers/AsgClientServiceManager.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/legacy/managers/AsgClientServiceManager.java @@ -4,7 +4,6 @@ import android.util.Log; import androidx.annotation.NonNull; import com.mentra.asg_client.io.bes.BesOtaManager; -import com.mentra.asg_client.io.bes.BesOtaRegistry; 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.K900BluetoothManager; @@ -14,6 +13,8 @@ import com.mentra.asg_client.io.media.managers.MediaUploadQueueManager; import com.mentra.asg_client.io.network.core.NetworkManagerFactory; import com.mentra.asg_client.io.network.interfaces.INetworkManager; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; import com.mentra.asg_client.io.server.core.DefaultServerFactory; import com.mentra.asg_client.io.server.managers.AsgServerManager; import com.mentra.asg_client.io.server.services.AsgCameraServer; @@ -61,7 +62,7 @@ public class AsgClientServiceManager { private RgbLedCommandHandler rgbLedCommandHandler; private final FileManager fileManager; - private final BesOtaRegistry besOtaRegistry; + private final IBesOtaRegistry besOtaRegistry; // StateManager for battery monitoring (set after construction) private IStateManager stateManager; @@ -74,7 +75,7 @@ public AsgClientServiceManager( @NonNull AsgClientService service, ICommunicationManager communicationManager, FileManager fileManager, - BesOtaRegistry besOtaRegistry, + IBesOtaRegistry besOtaRegistry, ICompanionTransport transport, INetworkManager networkManager) { AsgClientService requiredService = Objects.requireNonNull(service, "service"); @@ -725,7 +726,7 @@ public AsgServerManager getServerManager() { return serverManager; } - public BesOtaManager getBesOtaManager() { + public IBesOtaController getBesOtaManager() { return besOtaManager; } diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/di/hilt/OtaModuleTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/di/hilt/OtaModuleTest.java new file mode 100644 index 0000000000..528449ced9 --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/di/hilt/OtaModuleTest.java @@ -0,0 +1,39 @@ +package com.mentra.asg_client.di.hilt; + +import static org.assertj.core.api.Assertions.assertThat; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +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 org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Verifies {@link OtaModule} providers bind the core OTA interfaces to the vendor singletons. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 33) +public class OtaModuleTest { + + @Test + public void provideBesOtaRegistry_returnsSameUnderlyingInstance() { + BesOtaRegistry impl = new BesOtaRegistry(); + + IBesOtaRegistry bound = OtaModule.provideBesOtaRegistry(impl); + + assertThat(bound).isSameAs(impl); + } + + @Test + public void provideOtaHelper_wiresHelperWithRegistry() { + Context context = ApplicationProvider.getApplicationContext(); + IBesOtaRegistry registry = OtaModule.provideBesOtaRegistry(new BesOtaRegistry()); + + OtaHelper helper = OtaModule.provideOtaHelper(context, registry); + + assertThat(helper).isNotNull(); + helper.cleanup(); + } +} diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/di/hilt/TransportModuleTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/di/hilt/TransportModuleTest.java new file mode 100644 index 0000000000..2c238ceff5 --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/di/hilt/TransportModuleTest.java @@ -0,0 +1,76 @@ +package com.mentra.asg_client.di.hilt; + +import static org.assertj.core.api.Assertions.assertThat; + +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.StandardBluetoothManager; +import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.K900ProtocolStrategy; +import com.mentra.asg_client.io.network.interfaces.INetworkManager; +import com.mentra.asg_client.service.core.processors.CommandProtocolDetector; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowBuild; + +/** + * Verifies {@link TransportModule} vendor-wiring providers: device-appropriate transport/network + * selection and the unscoped (fresh-instance-per-injection) lifecycle contract. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 33) +public class TransportModuleTest { + + private static Context genericDeviceContext() { + ShadowBuild.setModel("Pixel 7"); + ShadowBuild.setProduct("pixel_7"); + ShadowBuild.setManufacturer("Google"); + return ApplicationProvider.getApplicationContext(); + } + + @Test + public void provideK900ProtocolStrategy_returnsMcuWireFormatStrategy() { + CommandProtocolDetector.ProtocolDetectionStrategy strategy = + TransportModule.provideK900ProtocolStrategy(); + + assertThat(strategy).isInstanceOf(K900ProtocolStrategy.class); + assertThat(strategy.getProtocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.K900_PROTOCOL); + } + + @Test + public void provideCompanionTransport_genericDevice_returnsStandardManager() { + Context context = genericDeviceContext(); + + ICompanionTransport transport = TransportModule.provideCompanionTransport(context); + + assertThat(transport).isInstanceOf(StandardBluetoothManager.class); + } + + @Test + public void provideCompanionTransport_unscoped_returnsFreshInstancePerCall() { + // Lifecycle contract: AsgClientServiceManager.cleanup() shuts the transport down on + // service destroy. A cached/singleton transport would be dead after a service restart, + // so every injection must build a fresh instance. + Context context = genericDeviceContext(); + + ICompanionTransport first = TransportModule.provideCompanionTransport(context); + ICompanionTransport second = TransportModule.provideCompanionTransport(context); + + assertThat(first).isNotSameAs(second); + } + + @Test + public void provideNetworkManager_returnsManagerAndFreshInstancePerCall() { + Context context = genericDeviceContext(); + + INetworkManager first = TransportModule.provideNetworkManager(context); + INetworkManager second = TransportModule.provideNetworkManager(context); + + assertThat(first).isNotNull(); + assertThat(second).isNotNull(); + assertThat(first).isNotSameAs(second); + } +} diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/io/bes/BesOtaManagerVersionTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/io/bes/BesOtaManagerVersionTest.java new file mode 100644 index 0000000000..758e5140c7 --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/io/bes/BesOtaManagerVersionTest.java @@ -0,0 +1,107 @@ +package com.mentra.asg_client.io.bes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.SerialPortBridge; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; +import com.mentra.asg_client.service.core.handlers.K900CommandHandler; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Exercises the version-comparison methods of {@code BesOtaManager} through the core {@link + * IBesOtaController} interface (these were previously statics consumed directly by {@code + * OtaHelper}). + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 33) +public class BesOtaManagerVersionTest { + + private IBesOtaController controller; + private boolean previousInProgressFlag; + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + controller = + new BesOtaManager( + mock(SerialPortBridge.class), context, mock(K900CommandHandler.class)); + previousInProgressFlag = BesOtaManager.isBesOtaInProgress; + } + + @After + public void tearDown() { + // The in-progress flag is static — restore it so state never leaks across tests. + BesOtaManager.isBesOtaInProgress = previousInProgressFlag; + } + + @Test + public void parseServerVersionCode_splitsMajorMinorPatch() { + // XYYYZZZ format: 17026001 -> major 17, minor 26, patch 1, build 0 + byte[] version = controller.parseServerVersionCode(17026001L); + + assertThat(version).containsExactly((byte) 17, (byte) 26, (byte) 1, (byte) 0); + } + + @Test + public void parseServerVersionCode_zero_isAllZero() { + assertThat(controller.parseServerVersionCode(0L)) + .containsExactly((byte) 0, (byte) 0, (byte) 0, (byte) 0); + } + + @Test + public void isNewerVersion_newerMajor_true() { + byte[] v1 = {2, 0, 0, 0}; + byte[] v2 = {1, 9, 9, 9}; + + assertThat(controller.isNewerVersion(v1, v2)).isTrue(); + } + + @Test + public void isNewerVersion_olderPatch_false() { + byte[] v1 = {1, 2, 3, 0}; + byte[] v2 = {1, 2, 4, 0}; + + assertThat(controller.isNewerVersion(v1, v2)).isFalse(); + } + + @Test + public void isNewerVersion_equalVersions_false() { + byte[] v = {1, 2, 3, 4}; + + assertThat(controller.isNewerVersion(v, v)).isFalse(); + } + + @Test + public void isNewerVersion_newerBuild_true() { + byte[] v1 = {1, 2, 3, 5}; + byte[] v2 = {1, 2, 3, 4}; + + assertThat(controller.isNewerVersion(v1, v2)).isTrue(); + } + + @Test + public void isNewerVersion_nullOrShortInput_false() { + byte[] valid = {1, 2, 3, 4}; + + assertThat(controller.isNewerVersion(null, valid)).isFalse(); + assertThat(controller.isNewerVersion(valid, null)).isFalse(); + assertThat(controller.isNewerVersion(new byte[] {1, 2}, valid)).isFalse(); + } + + @Test + public void isBesOtaInProgress_reflectsStaticFlag() { + BesOtaManager.isBesOtaInProgress = false; + assertThat(controller.isBesOtaInProgress()).isFalse(); + + BesOtaManager.isBesOtaInProgress = true; + assertThat(controller.isBesOtaInProgress()).isTrue(); + } +} diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/io/bes/BesOtaRegistryTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/io/bes/BesOtaRegistryTest.java new file mode 100644 index 0000000000..17dd9f7b5e --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/io/bes/BesOtaRegistryTest.java @@ -0,0 +1,39 @@ +package com.mentra.asg_client.io.bes; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mentra.asg_client.io.ota.interfaces.IBesOtaController; +import com.mentra.asg_client.io.ota.interfaces.IBesOtaRegistry; +import org.junit.Test; +import org.mockito.Mockito; + +/** Verifies {@link BesOtaRegistry} behavior through the core {@link IBesOtaRegistry} interface. */ +public class BesOtaRegistryTest { + + @Test + public void getInstance_initiallyNull() { + IBesOtaRegistry registry = new BesOtaRegistry(); + + assertThat(registry.getInstance()).isNull(); + } + + @Test + public void setInstance_thenGetInstance_returnsSameController() { + IBesOtaRegistry registry = new BesOtaRegistry(); + IBesOtaController controller = Mockito.mock(IBesOtaController.class); + + registry.setInstance(controller); + + assertThat(registry.getInstance()).isSameAs(controller); + } + + @Test + public void clear_removesController() { + IBesOtaRegistry registry = new BesOtaRegistry(); + registry.setInstance(Mockito.mock(IBesOtaController.class)); + + registry.clear(); + + assertThat(registry.getInstance()).isNull(); + } +} diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManagerDefaultsTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManagerDefaultsTest.java new file mode 100644 index 0000000000..58e490dd73 --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/io/bluetooth/interfaces/IBluetoothManagerDefaultsTest.java @@ -0,0 +1,83 @@ +package com.mentra.asg_client.io.bluetooth.interfaces; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.Test; + +/** + * Proves the four transport-level hooks on {@link IBluetoothManager} are safe no-ops by default, + * so non-K900 transports (e.g. {@code StandardBluetoothManager}) need no changes. + */ +public class IBluetoothManagerDefaultsTest { + + /** Minimal transport implementing only the abstract methods — inherits all defaults. */ + private static final class MinimalTransport implements ICompanionTransport { + @Override + public void initialize() {} + + @Override + public void startAdvertising() {} + + @Override + public void stopAdvertising() {} + + @Override + public boolean isConnected() { + return false; + } + + @Override + public void disconnect() {} + + @Override + public boolean sendMessage(byte[] data) { + return false; + } + + @Override + public boolean sendMessage(byte[] data, IBluetoothManager.SendMessageCallback callback) { + return false; + } + + @Override + public boolean sendMessage( + byte[] data, + IBluetoothManager.SendMessageCallback callback, + IBluetoothManager.SendMessageGate gate) { + return false; + } + + @Override + public boolean sendFile(String filePath) { + return false; + } + + @Override + public boolean isFileTransferInProgress() { + return false; + } + + @Override + public void addBluetoothListener(TransportListener listener) {} + + @Override + public void removeBluetoothListener(TransportListener listener) {} + + @Override + public void shutdown() {} + } + + @Test + public void defaultTransportHooks_areNoOpsAndDoNotThrow() { + ICompanionTransport transport = new MinimalTransport(); + + assertThatCode( + () -> { + transport.onMtuNegotiated(251); + transport.onTransportReset(); + transport.onFileTransferConfirmation("photo.jpg", true); + transport.onFileTransferAck(1, 7); + }) + .doesNotThrowAnyException(); + } +} diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/io/hardware/interfaces/RgbLedConstantsTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/io/hardware/interfaces/RgbLedConstantsTest.java new file mode 100644 index 0000000000..cad45235d5 --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/io/hardware/interfaces/RgbLedConstantsTest.java @@ -0,0 +1,41 @@ +package com.mentra.asg_client.io.hardware.interfaces; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mentra.asg_client.hardware.K900RgbLedController; +import org.junit.Test; + +/** + * Pins the wire contract between the core {@link RgbLedConstants} and the vendor {@code + * K900RgbLedController} constants. + * + *

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 strategies) { + Context context = ApplicationProvider.getApplicationContext(); + return new CommandProcessor( + context, + mock(ICommunicationManager.class), + mock(IStateManager.class), + mock(IMediaManager.class), + mock(IResponseBuilder.class), + mock(IConfigurationManager.class), + serviceManager, + mock(FileManager.class), + mock(RgbLedCommandHandler.class), + mock(OtaCommandHandler.class), + peripheralBus, + strategies); + } + + private static byte[] bytes(JSONObject json) { + return json.toString().getBytes(StandardCharsets.UTF_8); + } + + @Test + public void mcuFrame_withInjectedK900Strategy_publishesTypedEventOnBus() throws Exception { + CommandProcessor processor = newProcessor(Set.of(new K900ProtocolStrategy())); + JSONObject mcuFrame = + new JSONObject() + .put("C", "cs_flts") + .put("B", new JSONObject().put("state", 1).put("index", 7)); + + processor.processCommand(bytes(mcuFrame)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(McuEvent.class); + verify(peripheralBus).publish(captor.capture()); + assertThat(captor.getValue()).isInstanceOf(FileTransferAckEvent.class); + FileTransferAckEvent ack = (FileTransferAckEvent) captor.getValue(); + assertThat(ack.getState()).isEqualTo(1); + assertThat(ack.getIndex()).isEqualTo(7); + } + + @Test + public void mcuFrame_withoutVendorStrategies_isDroppedWithoutException() throws Exception { + CommandProcessor processor = newProcessor(Collections.emptySet()); + JSONObject mcuFrame = + new JSONObject() + .put("C", "cs_flts") + .put("B", new JSONObject().put("state", 1).put("index", 7)); + + assertThatCode(() -> processor.processCommand(bytes(mcuFrame))) + .doesNotThrowAnyException(); + + verify(peripheralBus, never()).publish(any()); + } + + @Test + public void setBleMtu_jsonCommand_reachesTransportThroughInterface() throws Exception { + CommandProcessor processor = newProcessor(Set.of(new K900ProtocolStrategy())); + JSONObject command = new JSONObject().put("type", "set_ble_mtu").put("mtu", 251); + + processor.processCommand(bytes(command)); + + // Bytes -> parser -> detector -> registry -> BleConfigCommandHandler -> transport hook. + verify(transport).onMtuNegotiated(251); + } + + @Test + public void setBleMtu_stillRoutes_whenNoVendorStrategiesRegistered() throws Exception { + CommandProcessor processor = newProcessor(Collections.emptySet()); + JSONObject command = new JSONObject().put("type", "set_ble_mtu").put("mtu", 185); + + processor.processCommand(bytes(command)); + + verify(transport).onMtuNegotiated(185); + } + + @Test + public void transferComplete_jsonCommand_reachesTransportWithoutVendorCast() throws Exception { + // The transport is a plain ICompanionTransport mock — the old code would have thrown + // ClassCastException casting it to K900BluetoothManager. + CommandProcessor processor = newProcessor(Set.of(new K900ProtocolStrategy())); + JSONObject command = + new JSONObject() + .put("type", "transfer_complete") + .put("fileName", "photo.jpg") + .put("success", true); + + processor.processCommand(bytes(command)); + + verify(transport).onFileTransferConfirmation("photo.jpg", true); + } +} diff --git a/asg_client/app/src/test/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetectorTest.java b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetectorTest.java new file mode 100644 index 0000000000..89a55aba95 --- /dev/null +++ b/asg_client/app/src/test/java/com/mentra/asg_client/service/core/processors/CommandProtocolDetectorTest.java @@ -0,0 +1,122 @@ +package com.mentra.asg_client.service.core.processors; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.K900ProtocolStrategy; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Verifies the {@link CommandProtocolDetector} ships with only the base (vendor-free) strategies, + * and that runtime registration of {@link K900ProtocolStrategy} restores MCU wire-format detection + * without disturbing JSON or chunked routing — the seam introduced for the A6 module split. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 33) +public class CommandProtocolDetectorTest { + + private static JSONObject standardJson() throws Exception { + return new JSONObject().put("type", "ping").put("mId", 42); + } + + private static JSONObject cFieldWithValidJson() throws Exception { + return new JSONObject().put("C", new JSONObject().put("type", "ping").toString()); + } + + /** Mentra Live MCU frame: the C payload is a bare command string, not JSON. */ + private static JSONObject mcuFrame() throws Exception { + return new JSONObject().put("C", "cs_flts").put("B", new JSONObject().put("state", 1)); + } + + private static JSONObject chunkFrame() throws Exception { + JSONObject chunk = + new JSONObject() + .put("type", "chunked_msg") + .put("chunkId", "abc") + .put("chunk", 0) + .put("total", 2) + .put("data", "payload-part"); + return new JSONObject().put("C", chunk.toString()); + } + + @Test + public void baseDetector_standardJson_isJsonCommand() throws Exception { + CommandProtocolDetector detector = new CommandProtocolDetector(); + + CommandProtocolDetector.ProtocolDetectionResult result = + detector.detectProtocol(standardJson()); + + assertThat(result.protocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.JSON_COMMAND); + assertThat(result.commandType()).isEqualTo("ping"); + assertThat(result.messageId()).isEqualTo(42); + } + + @Test + public void baseDetector_cFieldWithValidJson_isJsonCommand() throws Exception { + CommandProtocolDetector detector = new CommandProtocolDetector(); + + CommandProtocolDetector.ProtocolDetectionResult result = + detector.detectProtocol(cFieldWithValidJson()); + + assertThat(result.protocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.JSON_COMMAND); + } + + @Test + public void baseDetector_mcuFrame_isUnknown_coreNoLongerKnowsK900() throws Exception { + CommandProtocolDetector detector = new CommandProtocolDetector(); + + CommandProtocolDetector.ProtocolDetectionResult result = + detector.detectProtocol(mcuFrame()); + + assertThat(result.protocolType()).isEqualTo(CommandProtocolDetector.ProtocolType.UNKNOWN); + } + + @Test + public void registeredK900Strategy_mcuFrame_isK900Protocol() throws Exception { + CommandProtocolDetector detector = new CommandProtocolDetector(); + detector.addDetectionStrategy(new K900ProtocolStrategy()); + + CommandProtocolDetector.ProtocolDetectionResult result = + detector.detectProtocol(mcuFrame()); + + assertThat(result.protocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.K900_PROTOCOL); + assertThat(result.isValid()).isTrue(); + } + + @Test + public void registeredK900Strategy_doesNotStealJsonCommands() throws Exception { + CommandProtocolDetector detector = new CommandProtocolDetector(); + detector.addDetectionStrategy(new K900ProtocolStrategy()); + + assertThat(detector.detectProtocol(standardJson()).protocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.JSON_COMMAND); + assertThat(detector.detectProtocol(cFieldWithValidJson()).protocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.JSON_COMMAND); + } + + @Test + public void chunkedSupport_withK900Registered_chunksStillRouteToChunkedStrategy() + throws Exception { + // Mirrors CommandProcessor wiring order: vendor strategies first, then chunked support. + CommandProtocolDetector detector = new CommandProtocolDetector(); + detector.addDetectionStrategy(new K900ProtocolStrategy()); + detector.addChunkedMessageSupport(new ChunkReassembler()); + + CommandProtocolDetector.ProtocolDetectionResult result = + detector.detectProtocol(chunkFrame()); + + // A first chunk is consumed by the chunked strategy and reported as in-progress — + // proving neither the K900 nor the JSON strategy intercepted it. + assertThat(result.commandType()).isEqualTo("chunk_in_progress"); + + // And the MCU frame still routes to K900 with chunked support active. + assertThat(detector.detectProtocol(mcuFrame()).protocolType()) + .isEqualTo(CommandProtocolDetector.ProtocolType.K900_PROTOCOL); + } +}