From ae9e414198660731e83e0aaa4efde216b64e3522 Mon Sep 17 00:00:00 2001 From: Sebastiano Poggi Date: Sat, 7 Feb 2026 23:02:25 +0900 Subject: [PATCH] Add remote shooting flow, vendor delegates, and detekt FQN rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a Remote Control navigation route and RemoteShootingScreen to enable manual camera control and live status monitoring. - Implement RemoteShootingViewModel to coordinate UI state, connection lifecycle, and delegate interactions. - Introduce RemoteControlDelegate/RemoteControlCapabilities contracts to support vendor-specific remote control logic with BLE now and Wi‑Fi tiering later. - Implement SonyRemoteControlDelegate and RicohRemoteControlDelegate with protocol-specific decoding of shooting status, battery, storage, and mode data. - Extend SonyProtocol and RicohProtocol with new encoders/decoders for remote control notifications and command payloads (e.g., FF02, CC09, CC10, CC0F, etc.). - Update CameraVendor/CameraConnection to expose remote control support and delegate access. - Add Remote Control entrypoint on DeviceCard, gated to connected devices. - Add vector assets for camera modes, drive modes, focus/shutter status, and battery indicators. - Add/migrate Compose UI tests to JVM unit tests for remote shooting UI components and DeviceCard behavior. - Add comprehensive unit tests for protocol parsing, delegate behavior, and coordinator interactions. - Add a detekt custom rule forbidding fully qualified app references and add tests for that rule. - Document in AGENTS.md that ./gradlew check is mandatory at the end of every task. --- AGENTS.md | 7 +- app/build.gradle.kts | 33 + app/src/main/AndroidManifest.xml | 1 - .../sebastiano/camerasync/CameraSyncApp.kt | 6 +- .../dev/sebastiano/camerasync/MainActivity.kt | 22 + .../dev/sebastiano/camerasync/NavRoute.kt | 3 + .../data/repository/KableCameraRepository.kt | 65 +- .../camerasync/devices/DeviceCard.kt | 99 ++- .../devices/DevicesListComponents.kt | 2 + .../camerasync/devices/DevicesListScreen.kt | 4 + .../devices/DevicesListViewModel.kt | 7 +- .../devicesync/AndroidIntentFactory.kt | 6 + .../devicesync/DeviceConnectionManager.kt | 46 ++ .../camerasync/devicesync/IntentFactory.kt | 3 + .../devicesync/MultiDeviceSyncCoordinator.kt | 63 +- .../devicesync/MultiDeviceSyncService.kt | 40 + .../dev/sebastiano/camerasync/di/AppGraph.kt | 3 + .../camerasync/di/MetroViewModelFactory.kt | 3 + .../domain/model/RemoteControlModels.kt | 151 ++++ .../domain/repository/CameraRepository.kt | 12 + .../camerasync/domain/vendor/CameraVendor.kt | 78 +- .../vendor/RemoteControlCapabilities.kt | 151 ++++ .../domain/vendor/RemoteControlDelegate.kt | 250 +++++++ .../ui/remote/RemoteShootingScreen.kt | 565 +++++++++++++++ .../ui/remote/RemoteShootingViewModel.kt | 244 +++++++ .../vendors/ricoh/RicohCameraVendor.kt | 137 +++- .../camerasync/vendors/ricoh/RicohGattSpec.kt | 104 ++- .../camerasync/vendors/ricoh/RicohProtocol.kt | 282 ++++++- .../ricoh/RicohRemoteControlDelegate.kt | 179 +++++ .../vendors/sony/SonyCameraVendor.kt | 99 ++- .../camerasync/vendors/sony/SonyGattSpec.kt | 25 +- .../camerasync/vendors/sony/SonyProtocol.kt | 182 ++++- .../vendors/sony/SonyRemoteControlDelegate.kt | 492 +++++++++++++ .../res/drawable/battery_android_1_24dp.xml | 12 + .../res/drawable/battery_android_2_24dp.xml | 12 + .../res/drawable/battery_android_3_24dp.xml | 12 + .../res/drawable/battery_android_4_24dp.xml | 12 + .../res/drawable/battery_android_5_24dp.xml | 12 + .../res/drawable/battery_android_6_24dp.xml | 12 + .../drawable/battery_android_alert_24dp.xml | 12 + .../drawable/battery_android_bolt_24dp.xml | 12 + .../drawable/battery_android_full_24dp.xml | 12 + .../battery_android_question_24dp.xml | 12 + app/src/main/res/drawable/bulb_24dp.xml | 5 + app/src/main/res/drawable/drive_bracket.xml | 36 + .../main/res/drawable/drive_continuous.xml | 21 + app/src/main/res/drawable/drive_single.xml | 11 + app/src/main/res/drawable/drive_timer_10s.xml | 31 + app/src/main/res/drawable/drive_timer_2s.xml | 26 + app/src/main/res/drawable/focus_auto_auto.xml | 11 + .../res/drawable/focus_auto_continuous.xml | 11 + .../main/res/drawable/focus_auto_single.xml | 11 + app/src/main/res/drawable/focus_manual.xml | 11 + app/src/main/res/drawable/mode_aperture.xml | 16 + app/src/main/res/drawable/mode_auto.xml | 16 + app/src/main/res/drawable/mode_manual.xml | 16 + .../main/res/drawable/mode_program_auto.xml | 16 + app/src/main/res/drawable/mode_shutter.xml | 16 + app/src/main/res/drawable/sd_card_24dp.xml | 12 + app/src/main/res/values/strings.xml | 27 + app/src/test/AndroidManifest.xml | 18 + .../repository/KableCameraConnectionTest.kt | 90 +++ .../camerasync/devices/DeviceCardTest.kt | 242 +++++++ .../devices/DevicesListViewModelTest.kt | 5 +- .../devicesync/DeviceConnectionManagerTest.kt | 56 ++ .../MultiDeviceSyncCoordinatorFirmwareTest.kt | 52 +- .../MultiDeviceSyncCoordinatorTest.kt | 23 +- .../devicesync/NotificationsTest.kt | 9 +- .../domain/vendor/CameraVendorRegistryTest.kt | 15 +- .../vendor/RemoteControlDelegateTest.kt | 71 ++ .../camerasync/fakes/FakeCameraConnection.kt | 15 + .../camerasync/fakes/FakeIntentFactory.kt | 16 + .../fakes/FakeRemoteControlDelegate.kt | 44 ++ .../camerasync/fakes/FakeVendorRegistry.kt | 18 +- .../fakes/ThrowingRemoteControlDelegate.kt | 24 + .../camerasync/testutils/WriteRecorder.kt | 92 +++ .../remote/RemoteShootingActionBannerTest.kt | 44 ++ .../ui/remote/RemoteShootingStatusBarTest.kt | 82 +++ .../ui/remote/RemoteShootingViewModelTest.kt | 221 ++++++ .../vendors/ricoh/RicohCameraVendorTest.kt | 93 ++- .../vendors/ricoh/RicohGattSpecTest.kt | 114 ++- .../ricoh/RicohProtocolRemoteControlTest.kt | 412 +++++++++++ .../vendors/ricoh/RicohProtocolTest.kt | 174 +++++ .../ricoh/RicohRemoteControlDelegateTest.kt | 323 +++++++++ .../vendors/sony/SonyCameraVendorTest.kt | 10 +- .../sony/SonyConnectionDelegateTest.kt | 23 +- .../vendors/sony/SonyProtocolConfigTest.kt | 75 ++ .../vendors/sony/SonyProtocolDateTimeTest.kt | 207 ++++++ .../vendors/sony/SonyProtocolLocationTest.kt | 321 ++++++++ .../sony/SonyProtocolRemoteControlTest.kt | 681 +++++++++++++++++ .../vendors/sony/SonyProtocolTest.kt | 685 ------------------ .../sony/SonyRemoteControlDelegateTest.kt | 531 ++++++++++++++ camerasync.af | Bin 0 -> 53674 bytes detekt-rules/build.gradle.kts | 15 + .../detekt/CameraSyncRuleSetProvider.kt | 12 + .../NoFullyQualifiedAppReferenceRule.kt | 91 +++ ...tlab.arturbosch.detekt.api.RuleSetProvider | 1 + .../NoFullyQualifiedAppReferenceRuleTest.kt | 79 ++ detekt.yml | 4 + docs/WIFI_REMOTE_SHUTTER_PLAN.md | 201 +++++ docs/ricoh/BATTERY.md | 4 +- docs/ricoh/DATA_MODELS.md | 16 +- docs/ricoh/EXTERNAL_REFERENCES.md | 83 +++ docs/ricoh/FIRMWARE_UPDATES.md | 3 +- docs/ricoh/GPS_LOCATION.md | 27 +- docs/ricoh/HTTP_WEBSOCKET.md | 177 ++--- docs/ricoh/IMAGE_CONTROL.md | 12 +- docs/ricoh/README.md | 236 +++--- docs/ricoh/WIFI_HANDOFF.md | 45 +- gradle/libs.versions.toml | 2 + settings.gradle.kts | 3 +- 111 files changed, 8341 insertions(+), 1233 deletions(-) create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/domain/model/RemoteControlModels.kt create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlCapabilities.kt create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegate.kt create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingScreen.kt create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModel.kt create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegate.kt create mode 100644 app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegate.kt create mode 100644 app/src/main/res/drawable/battery_android_1_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_2_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_3_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_4_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_5_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_6_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_alert_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_bolt_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_full_24dp.xml create mode 100644 app/src/main/res/drawable/battery_android_question_24dp.xml create mode 100644 app/src/main/res/drawable/bulb_24dp.xml create mode 100644 app/src/main/res/drawable/drive_bracket.xml create mode 100644 app/src/main/res/drawable/drive_continuous.xml create mode 100644 app/src/main/res/drawable/drive_single.xml create mode 100644 app/src/main/res/drawable/drive_timer_10s.xml create mode 100644 app/src/main/res/drawable/drive_timer_2s.xml create mode 100644 app/src/main/res/drawable/focus_auto_auto.xml create mode 100644 app/src/main/res/drawable/focus_auto_continuous.xml create mode 100644 app/src/main/res/drawable/focus_auto_single.xml create mode 100644 app/src/main/res/drawable/focus_manual.xml create mode 100644 app/src/main/res/drawable/mode_aperture.xml create mode 100644 app/src/main/res/drawable/mode_auto.xml create mode 100644 app/src/main/res/drawable/mode_manual.xml create mode 100644 app/src/main/res/drawable/mode_program_auto.xml create mode 100644 app/src/main/res/drawable/mode_shutter.xml create mode 100644 app/src/main/res/drawable/sd_card_24dp.xml create mode 100644 app/src/test/AndroidManifest.xml create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraConnectionTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/devices/DeviceCardTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManagerTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegateTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeRemoteControlDelegate.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/fakes/ThrowingRemoteControlDelegate.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/testutils/WriteRecorder.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingActionBannerTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingStatusBarTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModelTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolRemoteControlTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegateTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolConfigTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolDateTimeTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolLocationTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolRemoteControlTest.kt delete mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolTest.kt create mode 100644 app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegateTest.kt create mode 100644 camerasync.af create mode 100644 detekt-rules/build.gradle.kts create mode 100644 detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/CameraSyncRuleSetProvider.kt create mode 100644 detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRule.kt create mode 100644 detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider create mode 100644 detekt-rules/src/test/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRuleTest.kt create mode 100644 docs/WIFI_REMOTE_SHUTTER_PLAN.md create mode 100644 docs/ricoh/EXTERNAL_REFERENCES.md diff --git a/AGENTS.md b/AGENTS.md index 13ce9c3..4de3a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,9 @@ For complete details on the multi-vendor architecture, see [`docs/MULTI_VENDOR_S - Use Android Architecture Components where applicable - Maintain compatibility with Android 12+ (backup rules configured) - All new interfaces should have corresponding fake implementations for testing +- **MANDATORY:** Always run `./gradlew check` at the end of every task and address any failures before reporting completion. + +IMPORTANT: When applicable, and if the tool is available, prefer using android-studio-index MCP tools for code navigation and refactoring in the Android app and its dependencies. ### Key Features 1. **Multi-Device Support**: Pair and sync multiple cameras simultaneously. @@ -212,9 +215,9 @@ By injecting the dispatcher, tests can pass `UnconfinedTestDispatcher()` or `Sta ./gradlew build ``` -### Running Tests +### Running Tests and static analysis ```bash -./gradlew test +./gradlew check ``` ### Installing Debug Build diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7204df..aa8d9e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,11 +77,39 @@ android { } buildFeatures { compose = true } + testOptions { unitTests.isIncludeAndroidResources = true } installation { installOptions += listOf("--user", "0") } } ktfmt { kotlinLangStyle() } +tasks.withType().configureEach { + if (name == "testReleaseUnitTest") { + // Robolectric fails to resolve test activities for the release variant. + // Debug unit tests still run, so keep release unit tests disabled for now. + enabled = false + return@configureEach + } + if (!name.endsWith("UnitTest")) { + return@configureEach + } + val variantName = name.removePrefix("test").removeSuffix("UnitTest") + if (variantName.isEmpty()) { + return@configureEach + } + val variantDir = variantName.replaceFirstChar { it.lowercase() } + val manifestPath = + layout.buildDirectory + .file( + "intermediates/packaged_manifests/${variantDir}UnitTest/" + + "process${variantName}UnitTestManifest/AndroidManifest.xml" + ) + .get() + .asFile + .absolutePath + doFirst { systemProperty("robolectric.manifest", manifestPath) } +} + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } detekt { @@ -126,6 +154,10 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.mockwebserver) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.ui.test.junit4) + testImplementation(libs.androidx.ui.test.manifest) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) @@ -133,6 +165,7 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) detektPlugins(libs.compose.rules.detekt) + detektPlugins(project(":detekt-rules")) } // Setup protobuf configuration, generating lite Java and Kotlin classes diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28cc5f3..8374eb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -84,7 +84,6 @@ android:theme="@style/Theme.CameraSync" tools:replace="android:appComponentFactory" tools:targetApi="34"> - ().create(application = this) } private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/MainActivity.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/MainActivity.kt index 7d745f4..14795b8 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/MainActivity.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/MainActivity.kt @@ -43,6 +43,8 @@ import dev.sebastiano.camerasync.logging.LogViewerViewModel import dev.sebastiano.camerasync.pairing.PairingScreen import dev.sebastiano.camerasync.pairing.PairingViewModel import dev.sebastiano.camerasync.permissions.PermissionsScreen +import dev.sebastiano.camerasync.ui.remote.RemoteShootingScreen +import dev.sebastiano.camerasync.ui.remote.RemoteShootingViewModel import dev.sebastiano.camerasync.ui.theme.CameraSyncTheme import dev.zacsweers.metro.Inject @@ -184,6 +186,9 @@ private fun RootComposable( viewModel = devicesListViewModel, onAddDeviceClick = { backStack.add(NavRoute.Pairing) }, onViewLogsClick = { backStack.add(NavRoute.LogViewer) }, + onRemoteControlClick = { macAddress -> + backStack.add(NavRoute.RemoteControl(macAddress)) + }, ) } @@ -227,6 +232,23 @@ private fun RootComposable( }, ) } + + is NavRoute.RemoteControl -> { + val remoteShootingViewModel: RemoteShootingViewModel = + viewModel(factory = viewModelFactory) + val macAddress = key.macAddress + + LaunchedEffect(macAddress) { + remoteShootingViewModel.loadDevice(macAddress) + } + + RemoteShootingScreen( + viewModel = remoteShootingViewModel, + onBackClick = { + if (backStack.size > 1) backStack.removeAt(backStack.lastIndex) + }, + ) + } } } } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/NavRoute.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/NavRoute.kt index 6e51f7f..e602439 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/NavRoute.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/NavRoute.kt @@ -18,4 +18,7 @@ sealed interface NavRoute : Parcelable { /** Log viewer screen. */ @Parcelize @Serializable data object LogViewer : NavRoute + + /** Remote control screen for a specific device. */ + @Parcelize @Serializable data class RemoteControl(val macAddress: String) : NavRoute } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraRepository.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraRepository.kt index 228c93c..0bb531b 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraRepository.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraRepository.kt @@ -20,6 +20,7 @@ import dev.sebastiano.camerasync.domain.model.GpsLocation import dev.sebastiano.camerasync.domain.repository.CameraConnection import dev.sebastiano.camerasync.domain.repository.CameraRepository import dev.sebastiano.camerasync.domain.vendor.CameraVendorRegistry +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate import dev.sebastiano.camerasync.domain.vendor.VendorConnectionDelegate import dev.sebastiano.camerasync.logging.KhronicleLogEngine import java.io.IOException @@ -126,14 +127,31 @@ class KableCameraRepository( .setMatchMode(ScanSettings.MATCH_MODE_STICKY) .build() - // We need to filter by Service UUIDs to avoid waking up for every BLE device - val filters = - vendorRegistry.getAllScanFilterUuids().map { uuid -> + val filters = mutableListOf() + + // Service UUID filters (if provided by vendors). + vendorRegistry.getAllScanFilterUuids().forEach { uuid -> + filters.add( android.bluetooth.le.ScanFilter.Builder() .setServiceUuid( android.os.ParcelUuid(java.util.UUID.fromString(uuid.toString())) ) .build() + ) + } + + // Manufacturer data filters (e.g., Ricoh company ID 0x065F). + vendorRegistry + .getAllVendors() + .flatMap { it.gattSpec.scanFilterManufacturerData } + .forEach { filter -> + val builder = android.bluetooth.le.ScanFilter.Builder() + if (filter.mask != null) { + builder.setManufacturerData(filter.manufacturerId, filter.data, filter.mask) + } else { + builder.setManufacturerData(filter.manufacturerId, filter.data) + } + filters.add(builder.build()) } Log.info(tag = TAG) { @@ -349,10 +367,10 @@ internal class KableCameraConnection( private val gattSpec = camera.vendor.gattSpec private val protocol = camera.vendor.protocol - private val capabilities = camera.vendor.getCapabilities() + private val syncCapabilities = camera.vendor.getSyncCapabilities() override suspend fun initializePairing(): Boolean { - if (!capabilities.requiresVendorPairing) { + if (!syncCapabilities.requiresVendorPairing) { Log.info(tag = TAG) { "${camera.vendor.vendorName} cameras do not require vendor-specific pairing" } @@ -401,7 +419,7 @@ internal class KableCameraConnection( } override suspend fun readFirmwareVersion(): String { - if (!capabilities.supportsFirmwareVersion) { + if (!syncCapabilities.supportsFirmwareVersion) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support firmware version reading" ) @@ -424,7 +442,7 @@ internal class KableCameraConnection( } override suspend fun readHardwareRevision(): String { - if (!capabilities.supportsHardwareRevision) { + if (!syncCapabilities.supportsHardwareRevision) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support hardware revision reading" ) @@ -448,7 +466,7 @@ internal class KableCameraConnection( } override suspend fun setPairedDeviceName(name: String) { - if (!capabilities.supportsDeviceName) { + if (!syncCapabilities.supportsDeviceName) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support setting paired device name" ) @@ -473,7 +491,7 @@ internal class KableCameraConnection( /** Syncs date/time to the camera. */ override suspend fun syncDateTime(dateTime: ZonedDateTime) { - if (!capabilities.supportsDateTimeSync) { + if (!syncCapabilities.supportsDateTimeSync) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support date/time synchronization" ) @@ -489,7 +507,7 @@ internal class KableCameraConnection( } override suspend fun readDateTime(): ByteArray { - if (!capabilities.supportsDateTimeSync) { + if (!syncCapabilities.supportsDateTimeSync) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support date/time reading" ) @@ -510,7 +528,7 @@ internal class KableCameraConnection( } override suspend fun setGeoTaggingEnabled(enabled: Boolean) { - if (!capabilities.supportsGeoTagging) { + if (!syncCapabilities.supportsGeoTagging) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support geo-tagging control" ) @@ -542,7 +560,7 @@ internal class KableCameraConnection( } override suspend fun isGeoTaggingEnabled(): Boolean { - if (!capabilities.supportsGeoTagging) { + if (!syncCapabilities.supportsGeoTagging) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support geo-tagging" ) @@ -564,8 +582,10 @@ internal class KableCameraConnection( } /** Syncs a GPS location to the camera. */ + override fun supportsLocationSync(): Boolean = syncCapabilities.supportsLocationSync + override suspend fun syncLocation(location: GpsLocation) { - if (!capabilities.supportsLocationSync) { + if (!syncCapabilities.supportsLocationSync) { throw UnsupportedOperationException( "${camera.vendor.vendorName} cameras do not support location synchronization" ) @@ -587,6 +607,25 @@ internal class KableCameraConnection( peripheral.disconnect() } + @Volatile private var remoteControlDelegate: RemoteControlDelegate? = null + + private val remoteControlDelegateLock = Any() + + @Suppress("TooGenericExceptionCaught") + override fun getRemoteControlDelegate(): RemoteControlDelegate? { + if (remoteControlDelegate != null) return remoteControlDelegate + return synchronized(remoteControlDelegateLock) { + if (remoteControlDelegate != null) return@synchronized remoteControlDelegate + try { + remoteControlDelegate = + camera.vendor.createRemoteControlDelegate(peripheral, camera) + } catch (e: RuntimeException) { + Log.error(tag = TAG, throwable = e) { "Failed to create RemoteControlDelegate" } + } + remoteControlDelegate + } + } + companion object { private const val TAG = "KableCameraConnection" } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DeviceCard.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DeviceCard.kt index b2967ca..1e0a079 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DeviceCard.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DeviceCard.kt @@ -11,10 +11,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -48,12 +50,21 @@ internal fun DeviceCard( onEnabledChange: (Boolean) -> Unit, onUnpairClick: () -> Unit, onRetryClick: () -> Unit, + onRemoteControlClick: () -> Unit, ) { var isExpanded by remember { mutableStateOf(false) } val device = deviceWithState.device val connectionState = deviceWithState.connectionState val unknownString = stringResource(R.string.label_unknown) - val info = displayInfo ?: DeviceDisplayInfo(unknownString, unknownString, device.name, false) + val info = + displayInfo + ?: DeviceDisplayInfo( + unknownString, + unknownString, + device.name, + supportsRemoteControl = false, + showPairingName = false, + ) Card( modifier = Modifier.fillMaxWidth(), @@ -208,12 +219,36 @@ internal fun DeviceCard( Spacer(Modifier.height(12.dp)) - // Unpair action - TextButton(onClick = onUnpairClick, modifier = Modifier.align(Alignment.End)) { - Text( - stringResource(R.string.unpair_device), - color = MaterialTheme.colorScheme.error, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Remote Control action - visible only when connected + if ( + info.supportsRemoteControl && + (connectionState is DeviceConnectionState.Connected || + connectionState is DeviceConnectionState.Syncing) + ) { + FilledTonalButton(onClick = onRemoteControlClick) { + Icon( + painterResource(R.drawable.ic_photo_camera_24dp), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.remote_shooting_title)) + } + } else { + Spacer(Modifier.weight(1f)) + } + + // Unpair action + TextButton(onClick = onUnpairClick) { + Text( + stringResource(R.string.unpair_device), + color = MaterialTheme.colorScheme.error, + ) + } } } } @@ -290,10 +325,18 @@ private fun DeviceCardDisconnectedPreview() { ), connectionState = DeviceConnectionState.Disconnected, ), - displayInfo = DeviceDisplayInfo("Ricoh", "GR IIIx", "My Camera", true), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + "My Camera", + supportsRemoteControl = true, + showPairingName = true, + ), onEnabledChange = {}, onUnpairClick = {}, onRetryClick = {}, + onRemoteControlClick = {}, ) } } @@ -317,10 +360,18 @@ private fun DeviceCardSyncingPreview() { ), connectionState = DeviceConnectionState.Syncing(firmwareVersion = "1.10"), ), - displayInfo = DeviceDisplayInfo("Ricoh", "GR IIIx", null, false), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = true, + showPairingName = false, + ), onEnabledChange = {}, onUnpairClick = {}, onRetryClick = {}, + onRemoteControlClick = {}, ) } } @@ -341,10 +392,18 @@ private fun DeviceCardUnreachablePreview() { ), connectionState = DeviceConnectionState.Unreachable, ), - displayInfo = DeviceDisplayInfo("Sony", "Alpha 7 IV", "Studio A", true), + displayInfo = + DeviceDisplayInfo( + "Sony", + "Alpha 7 IV", + "Studio A", + supportsRemoteControl = true, + showPairingName = true, + ), onEnabledChange = {}, onUnpairClick = {}, onRetryClick = {}, + onRemoteControlClick = {}, ) } } @@ -369,10 +428,18 @@ private fun DeviceCardErrorPreview() { isRecoverable = true, ), ), - displayInfo = DeviceDisplayInfo("Ricoh", "GR IIIx", null, false), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = true, + showPairingName = false, + ), onEnabledChange = {}, onUnpairClick = {}, onRetryClick = {}, + onRemoteControlClick = {}, ) } } @@ -393,10 +460,18 @@ private fun DeviceCardDisabledPreview() { ), connectionState = DeviceConnectionState.Disabled, ), - displayInfo = DeviceDisplayInfo("Ricoh", "GR IIIx", null, false), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = true, + showPairingName = false, + ), onEnabledChange = {}, onUnpairClick = {}, onRetryClick = {}, + onRemoteControlClick = {}, ) } } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListComponents.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListComponents.kt index 881148e..ba595c0 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListComponents.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListComponents.kt @@ -76,6 +76,7 @@ internal fun DevicesList( onDeviceEnabledChange: (PairedDeviceWithState, Boolean) -> Unit, onUnpairClick: (PairedDeviceWithState) -> Unit, onRetryClick: (PairedDeviceWithState) -> Unit, + onRemoteControlClick: (String) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -99,6 +100,7 @@ internal fun DevicesList( }, onUnpairClick = { onUnpairClick(deviceWithState) }, onRetryClick = { onRetryClick(deviceWithState) }, + onRemoteControlClick = { onRemoteControlClick(deviceWithState.device.macAddress) }, ) } } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListScreen.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListScreen.kt index 03f0511..d883206 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListScreen.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListScreen.kt @@ -53,6 +53,7 @@ fun DevicesListScreen( viewModel: DevicesListViewModel, onAddDeviceClick: () -> Unit, onViewLogsClick: () -> Unit, + onRemoteControlClick: (String) -> Unit, ) { val state by viewModel.state var deviceToUnpair by remember { mutableStateOf(null) } @@ -100,6 +101,7 @@ fun DevicesListScreen( @SuppressLint("MissingPermission") viewModel.retryConnection(device.device.macAddress) }, + onRemoteControlClick = onRemoteControlClick, modifier = Modifier.padding(innerPadding), ) } @@ -237,6 +239,7 @@ private fun DevicesListContent( onDeviceEnabledChange: (PairedDeviceWithState, Boolean) -> Unit, onUnpairClick: (PairedDeviceWithState) -> Unit, onRetryClick: (PairedDeviceWithState) -> Unit, + onRemoteControlClick: (String) -> Unit, modifier: Modifier = Modifier, ) { when (state) { @@ -279,6 +282,7 @@ private fun DevicesListContent( onDeviceEnabledChange = onDeviceEnabledChange, onUnpairClick = onUnpairClick, onRetryClick = onRetryClick, + onRemoteControlClick = onRemoteControlClick, ) } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModel.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModel.kt index cc2ae20..75ac7cf 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModel.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.juul.khronicle.Log import dev.sebastiano.camerasync.R +import dev.sebastiano.camerasync.devicesync.IntentFactory import dev.sebastiano.camerasync.devicesync.MultiDeviceSyncService import dev.sebastiano.camerasync.domain.model.DeviceConnectionState import dev.sebastiano.camerasync.domain.model.GpsLocation @@ -60,7 +61,7 @@ class DevicesListViewModel( private val bluetoothBondingChecker: BluetoothBondingChecker, private val issueReporter: IssueReporter, private val batteryOptimizationChecker: BatteryOptimizationChecker, - private val intentFactory: dev.sebastiano.camerasync.devicesync.IntentFactory, + private val intentFactory: IntentFactory, private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -404,11 +405,14 @@ class DevicesListViewModel( val make = vendor?.vendorName ?: device.vendorId.replaceFirstChar { it.uppercase() } val model = vendor?.extractModelFromPairingName(device.name) ?: device.name ?: unknownString + val supportsRemoteControl = + vendor?.getRemoteControlCapabilities()?.remoteCapture?.supported == true device.macAddress to DeviceDisplayInfo( make = make, model = model, pairingName = device.name, + supportsRemoteControl = supportsRemoteControl, showPairingName = false, // Will be updated below ) } @@ -444,6 +448,7 @@ data class DeviceDisplayInfo( val make: String, val model: String, val pairingName: String?, + val supportsRemoteControl: Boolean, val showPairingName: Boolean, ) diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/AndroidIntentFactory.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/AndroidIntentFactory.kt index efd571b..459af9e 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/AndroidIntentFactory.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/AndroidIntentFactory.kt @@ -9,6 +9,12 @@ class AndroidIntentFactory(private val serviceClass: Class<*>) : IntentFactory { override fun createRefreshIntent(context: Context): Intent = Intent(context, serviceClass).apply { action = MultiDeviceSyncService.ACTION_REFRESH } + override fun createRefreshDeviceIntent(context: Context, macAddress: String): Intent = + Intent(context, serviceClass).apply { + action = MultiDeviceSyncService.ACTION_REFRESH_DEVICE + putExtra(MultiDeviceSyncService.EXTRA_DEVICE_ADDRESS, macAddress) + } + override fun createStopIntent(context: Context): Intent = Intent(context, serviceClass).apply { action = MultiDeviceSyncService.ACTION_STOP } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManager.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManager.kt index e6beb13..bbdadbc 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManager.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManager.kt @@ -1,8 +1,14 @@ package dev.sebastiano.camerasync.devicesync +import dev.sebastiano.camerasync.di.AppGraph import dev.sebastiano.camerasync.domain.repository.CameraConnection import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map /** * Manages active camera connections and their associated coroutine jobs. @@ -12,9 +18,11 @@ import kotlinx.coroutines.Job * thread-safe. */ @Inject +@SingleIn(AppGraph::class) class DeviceConnectionManager { private val deviceJobs = mutableMapOf() private val deviceConnections = mutableMapOf() + private val _connectionsState = MutableStateFlow>(emptyMap()) /** * Adds a new active connection for a device. @@ -25,8 +33,11 @@ class DeviceConnectionManager { */ fun addConnection(macAddress: String, connection: CameraConnection, job: Job) { synchronized(this) { + deviceJobs.remove(macAddress)?.cancel() + deviceConnections.remove(macAddress) deviceJobs[macAddress] = job deviceConnections[macAddress] = connection + _connectionsState.value = deviceConnections.toMap() } } @@ -43,6 +54,28 @@ class DeviceConnectionManager { return synchronized(this) { val connection = deviceConnections.remove(macAddress) val job = deviceJobs.remove(macAddress) + _connectionsState.value = deviceConnections.toMap() + connection to job + } + } + + /** + * Removes the connection only if the currently tracked job matches [expectedJob]. + * + * This prevents stale jobs from tearing down a newer connection after a reconnect. + */ + fun removeConnectionIfMatches( + macAddress: String, + expectedJob: Job, + ): Pair { + return synchronized(this) { + val currentJob = deviceJobs[macAddress] + if (currentJob != expectedJob) { + return@synchronized null to null + } + val connection = deviceConnections.remove(macAddress) + val job = deviceJobs.remove(macAddress) + _connectionsState.value = deviceConnections.toMap() connection to job } } @@ -63,4 +96,17 @@ class DeviceConnectionManager { */ fun getConnection(macAddress: String): CameraConnection? = synchronized(this) { deviceConnections[macAddress] } + + /** + * Flow of the active connection for a specific device. + * + * Emits whenever the connection for [macAddress] is added, removed, or replaced (e.g. after BLE + * disconnect and reconnect). Observers can refresh UI or delegate references when the + * connection instance changes. + * + * @param macAddress The MAC address of the device. + * @return A [Flow] that emits the current [CameraConnection], or null when not connected. + */ + fun connectionFlow(macAddress: String): Flow = + _connectionsState.map { it[macAddress] }.distinctUntilChanged() } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/IntentFactory.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/IntentFactory.kt index 8370056..a62f35c 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/IntentFactory.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/IntentFactory.kt @@ -8,6 +8,9 @@ interface IntentFactory { /** Creates an [Intent] to trigger a connection refresh. */ fun createRefreshIntent(context: Context): Intent + /** Creates an [Intent] to refresh a specific device connection. */ + fun createRefreshDeviceIntent(context: Context, macAddress: String): Intent + /** Creates an [Intent] to stop all synchronizations. */ fun createStopIntent(context: Context): Intent diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinator.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinator.kt index 89bdd77..889aed2 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinator.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinator.kt @@ -313,6 +313,7 @@ constructor( coroutineScope.launch { val macAddress = device.macAddress.uppercase() var activeConnection: CameraConnection? = null + var syncJob: Job? = null connectionMutex.withLock { if (isDeviceSyncingOrConnecting(macAddress)) { @@ -351,16 +352,16 @@ constructor( val firmwareVersion = performInitialSetup(connection, device) + val currentJob = + currentCoroutineContext()[Job] ?: coroutineScope.launch {}.also { it.cancel() } + syncJob = currentJob + connectionManager.addConnection(macAddress, connection, currentJob) + locationCollector.registerDevice(macAddress) + _presentDevices.update { it + macAddress } updateDeviceState( macAddress, DeviceConnectionState.Syncing(firmwareVersion = firmwareVersion), ) - _presentDevices.update { it + macAddress } - - val syncJob = - currentCoroutineContext()[Job] ?: coroutineScope.launch {}.also { it.cancel() } - connectionManager.addConnection(macAddress, connection, syncJob) - locationCollector.registerDevice(macAddress) // Ensure location sync is running for all connected devices ensureLocationSyncRunning() @@ -408,21 +409,30 @@ constructor( DeviceConnectionState.Error(e.message ?: "Unknown error", isRecoverable = true), ) } finally { - locationCollector.unregisterDevice(macAddress) - val (managedConnection, _) = connectionManager.removeConnection(macAddress) + val (managedConnection, removedJob) = + syncJob?.let { job -> + connectionManager.removeConnectionIfMatches(macAddress, job) + } ?: (null to null) + if (removedJob != null) { + locationCollector.unregisterDevice(macAddress) + } (managedConnection ?: activeConnection)?.disconnect() // We don't cancel the job here as this finally block runs IN that job // or if the job was cancelled externally. // The connection removal ensures future lookups don't find it. - _presentDevices.update { it - macAddress } - - val state = getDeviceState(macAddress) - if ( - state is DeviceConnectionState.Connected || - state is DeviceConnectionState.Syncing - ) { - updateDeviceState(macAddress, DeviceConnectionState.Disconnected) + if (removedJob != null) { + _presentDevices.update { it - macAddress } + } + + if (removedJob != null) { + val state = getDeviceState(macAddress) + if ( + state is DeviceConnectionState.Connected || + state is DeviceConnectionState.Syncing + ) { + updateDeviceState(macAddress, DeviceConnectionState.Disconnected) + } } } } @@ -442,9 +452,9 @@ constructor( ): String? { Log.info(tag = TAG) { "Performing initial setup for ${device.macAddress}" } - val capabilities = connection.camera.vendor.getCapabilities() + val syncCapabilities = connection.camera.vendor.getSyncCapabilities() - if (capabilities.supportsDeviceName) { + if (syncCapabilities.supportsDeviceName) { try { val deviceName = connection.camera.vendor.getPairedDeviceName(deviceNameProvider) connection.setPairedDeviceName(deviceName) @@ -469,7 +479,7 @@ constructor( val gattSpec = connection.camera.vendor.gattSpec val usesUnifiedPacket = gattSpec.dateTimeCharacteristicUuid == gattSpec.locationCharacteristicUuid - val shouldSyncDateTime = capabilities.supportsDateTimeSync && !usesUnifiedPacket + val shouldSyncDateTime = syncCapabilities.supportsDateTimeSync && !usesUnifiedPacket if (shouldSyncDateTime) { try { connection.syncDateTime(ZonedDateTime.now()) @@ -480,14 +490,14 @@ constructor( "Failed to sync date time for ${device.macAddress}" } } - } else if (capabilities.supportsDateTimeSync && usesUnifiedPacket) { + } else if (syncCapabilities.supportsDateTimeSync && usesUnifiedPacket) { Log.debug(tag = TAG) { "Skipping initial date/time sync for ${device.macAddress} - " + "camera uses unified time+location packets, will sync with first location update" } } - if (capabilities.supportsGeoTagging) { + if (syncCapabilities.supportsGeoTagging) { try { connection.setGeoTaggingEnabled(true) } catch (e: CancellationException) { @@ -500,7 +510,7 @@ constructor( } var firmwareVersion: String? = null - if (capabilities.supportsFirmwareVersion) { + if (syncCapabilities.supportsFirmwareVersion) { try { firmwareVersion = connection.readFirmwareVersion() pairedDevicesRepository.updateFirmwareVersion(device.macAddress, firmwareVersion) @@ -520,8 +530,9 @@ constructor( * Stops synchronization for a specific device. * * @param macAddress The MAC address of the device to stop syncing. + * @param awaitCompletion When true, waits for the sync job to finish before returning. */ - suspend fun stopDeviceSync(macAddress: String) { + suspend fun stopDeviceSync(macAddress: String, awaitCompletion: Boolean = false) { val upperMacAddress = macAddress.uppercase() // Stop location updates first @@ -531,7 +542,11 @@ constructor( val (connection, job) = connectionManager.removeConnection(upperMacAddress) connection?.disconnect() job?.cancel() + if (awaitCompletion) { + job?.join() + } + _presentDevices.update { it - upperMacAddress } updateDeviceState(upperMacAddress, DeviceConnectionState.Disconnected) } @@ -699,7 +714,7 @@ constructor( connection: CameraConnection, location: GpsLocation, ) { - if (!connection.camera.vendor.getCapabilities().supportsLocationSync) return + if (!connection.supportsLocationSync()) return try { connection.syncLocation(location) val now = System.currentTimeMillis() diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncService.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncService.kt index 583cf6b..33c3714 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncService.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncService.kt @@ -135,6 +135,30 @@ class MultiDeviceSyncService( launch { refreshConnections() } FirmwareUpdateScheduler.triggerOneTimeCheck(this) } + ACTION_REFRESH_DEVICE -> { + val macAddress = intent.getStringExtra(EXTRA_DEVICE_ADDRESS) + if (macAddress.isNullOrBlank()) { + Log.warn(tag = TAG) { "Refresh device intent missing mac address" } + return START_NOT_STICKY + } + Log.info(tag = TAG) { "Received device refresh intent for $macAddress" } + if (!checkPermissions()) return START_NOT_STICKY + startForegroundService() + startDeviceMonitoring() + launch { + val device = pairedDevicesRepository.getDevice(macAddress) + if (device == null) { + Log.warn(tag = TAG) { "Device $macAddress not found for refresh" } + return@launch + } + if (!device.isEnabled) { + Log.warn(tag = TAG) { "Device $macAddress is disabled; skipping refresh" } + return@launch + } + syncCoordinator.stopDeviceSync(macAddress, awaitCompletion = true) + syncCoordinator.startDeviceSync(device) + } + } ACTION_DEVICE_FOUND -> { Log.info(tag = TAG) { "Received device found intent, starting service and connecting..." @@ -486,6 +510,9 @@ class MultiDeviceSyncService( /** Action to manually refresh connections. */ const val ACTION_REFRESH = "dev.sebastiano.camerasync.REFRESH_CONNECTIONS" + /** Action to refresh a single device connection. */ + const val ACTION_REFRESH_DEVICE = "dev.sebastiano.camerasync.REFRESH_DEVICE" + /** Action triggered when a device is found during background scanning. */ const val ACTION_DEVICE_FOUND = "dev.sebastiano.camerasync.DEVICE_FOUND" @@ -531,6 +558,19 @@ class MultiDeviceSyncService( fun createRefreshIntent(context: Context): Intent = Intent(context, MultiDeviceSyncService::class.java).apply { action = ACTION_REFRESH } + /** + * Creates an [Intent] to refresh a single device connection. + * + * @param context The context. + * @param macAddress The device MAC address. + * @return The refresh intent for the device. + */ + fun createRefreshDeviceIntent(context: Context, macAddress: String): Intent = + Intent(context, MultiDeviceSyncService::class.java).apply { + action = ACTION_REFRESH_DEVICE + putExtra(EXTRA_DEVICE_ADDRESS, macAddress) + } + /** * Creates an [Intent] to notify that a device was found. * diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/di/AppGraph.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/di/AppGraph.kt index 3fbc330..54301f5 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/di/AppGraph.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/di/AppGraph.kt @@ -36,6 +36,7 @@ import dev.sebastiano.camerasync.pairing.AndroidBluetoothBondingChecker import dev.sebastiano.camerasync.pairing.BluetoothBondingChecker import dev.sebastiano.camerasync.pairing.CompanionDeviceManagerHelper import dev.sebastiano.camerasync.pairing.PairingViewModel +import dev.sebastiano.camerasync.ui.remote.RemoteShootingViewModel import dev.sebastiano.camerasync.util.AndroidBatteryOptimizationChecker import dev.sebastiano.camerasync.util.AndroidDeviceNameProvider import dev.sebastiano.camerasync.util.BatteryOptimizationChecker @@ -73,6 +74,8 @@ interface AppGraph { fun pairingViewModel(): PairingViewModel + fun remoteShootingViewModel(): RemoteShootingViewModel + fun logViewerViewModel(): LogViewerViewModel fun pairedDevicesRepository(): PairedDevicesRepository diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/di/MetroViewModelFactory.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/di/MetroViewModelFactory.kt index 93bc15d..0b3feeb 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/di/MetroViewModelFactory.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/di/MetroViewModelFactory.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider import dev.sebastiano.camerasync.devices.DevicesListViewModel import dev.sebastiano.camerasync.logging.LogViewerViewModel import dev.sebastiano.camerasync.pairing.PairingViewModel +import dev.sebastiano.camerasync.ui.remote.RemoteShootingViewModel /** * A [ViewModelProvider.Factory] that uses the [AppGraph] to create ViewModels. @@ -21,6 +22,8 @@ class MetroViewModelFactory(private val appGraph: AppGraph) : ViewModelProvider. appGraph.devicesListViewModel() as T modelClass.isAssignableFrom(PairingViewModel::class.java) -> appGraph.pairingViewModel() as T + modelClass.isAssignableFrom(RemoteShootingViewModel::class.java) -> + appGraph.remoteShootingViewModel() as T modelClass.isAssignableFrom(LogViewerViewModel::class.java) -> appGraph.logViewerViewModel() as T else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/model/RemoteControlModels.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/model/RemoteControlModels.kt new file mode 100644 index 0000000..6f997ab --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/model/RemoteControlModels.kt @@ -0,0 +1,151 @@ +@file:Suppress("unused") + +package dev.sebastiano.camerasync.domain.model + +import java.time.Duration + +/** Represents the status of the camera's battery. */ +data class BatteryInfo( + val levelPercentage: Int, // 0-100 + val isCharging: Boolean = false, + val powerSource: PowerSource = PowerSource.BATTERY, + val position: BatteryPosition = BatteryPosition.INTERNAL, +) + +enum class PowerSource { + BATTERY, + USB, + AC_ADAPTER, + UNKNOWN, +} + +enum class BatteryPosition { + INTERNAL, + GRIP_1, + GRIP_2, + UNKNOWN, +} + +/** Represents the status of the camera's storage media. */ +data class StorageInfo( + val slot: Int = 1, + val isPresent: Boolean = false, + val isWriteProtected: Boolean = false, + val isFull: Boolean = false, + val remainingShots: Int? = null, + val remainingVideoDuration: Duration? = null, + val format: String? = null, // e.g., "RAW", "JPG" +) + +/** Represents the camera's high-level operation mode. */ +enum class CameraMode { + STILL_IMAGE, + MOVIE, + PLAYBACK, + PC_REMOTE, + UNKNOWN, +} + +/** Represents the capture status (shutter lifecycle). */ +sealed interface CaptureStatus { + data object Idle : CaptureStatus + + data object Focusing : CaptureStatus + + data object Capturing : CaptureStatus + + data class Countdown(val secondsRemaining: Int) : CaptureStatus + + data object Processing : CaptureStatus +} + +/** Represents the exposure program mode. */ +enum class ExposureMode { + PROGRAM_AUTO, // P + APERTURE_PRIORITY, // A / Av + SHUTTER_PRIORITY, // S / Tv + MANUAL, // M + BULB, // B + TIME, // T + BULB_TIMER, // BT + SNAP_FOCUS_PROGRAM, // SFP (Ricoh) + AUTO, // Full Auto + UNKNOWN, +} + +/** Represents the drive mode. */ +enum class DriveMode { + SINGLE_SHOOTING, + CONTINUOUS_SHOOTING, + SELF_TIMER_10S, + SELF_TIMER_2S, + BRACKET, + INTERVAL, + MULTI_EXPOSURE, + UNKNOWN, +} + +/** Represents the autofocus status. */ +enum class FocusStatus { + LOST, // Focus lost or not acquired + SEARCHING, // AF is running + LOCKED, // Focus locked/acquired + MANUAL, // Manual focus mode +} + +/** Represents the shutter release status. */ +enum class ShutterStatus { + READY, + ACTIVE, // Shutter is open (e.g. long exposure) + DISABLED, // Cannot release shutter +} + +/** Represents the video recording status. */ +enum class RecordingStatus { + IDLE, + RECORDING, + PAUSED, + PROCESSING, +} + +/** Represents a frame from the live view stream. */ +data class LiveViewFrame( + val jpegData: ByteArray, + val focusFrame: FocusFrame? = null, + // Add other overlay info as needed +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as LiveViewFrame + if (!jpegData.contentEquals(other.jpegData)) return false + if (focusFrame != other.focusFrame) return false + return true + } + + override fun hashCode(): Int { + var result = jpegData.contentHashCode() + result = 31 * result + (focusFrame?.hashCode() ?: 0) + return result + } +} + +data class FocusFrame( + val x: Float, // Normalized 0-1 + val y: Float, // Normalized 0-1 + val width: Float, // Normalized 0-1 + val height: Float, // Normalized 0-1 + val status: FocusStatus, +) + +/** Vendor-specific custom buttons. */ +enum class CustomButton { + AF_ON, + AEL, + C1, + C2, + C3, + C4, + USER, + UNKNOWN, +} diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/repository/CameraRepository.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/repository/CameraRepository.kt index 1bec040..d6923d0 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/repository/CameraRepository.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/repository/CameraRepository.kt @@ -2,6 +2,7 @@ package dev.sebastiano.camerasync.domain.repository import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.GpsLocation +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate import java.time.ZonedDateTime import kotlinx.coroutines.flow.Flow @@ -97,4 +98,15 @@ interface CameraConnection { /** Disconnects from the camera. */ suspend fun disconnect() + + /** + * Returns true if this connection's camera supports GPS location synchronization. + * + * Implementations should avoid allocating capability objects on each call (e.g. use a cached + * value) as this may be invoked on a hot path for every location update per device. + */ + fun supportsLocationSync(): Boolean + + /** Returns a RemoteControlDelegate for this connection, or creates one if supported. */ + fun getRemoteControlDelegate(): RemoteControlDelegate? } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendor.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendor.kt index 218f91d..08c3537 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendor.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendor.kt @@ -1,6 +1,8 @@ package dev.sebastiano.camerasync.domain.vendor import android.companion.DeviceFilter +import com.juul.kable.Peripheral +import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.GpsLocation import dev.sebastiano.camerasync.util.DeviceNameProvider import java.time.ZonedDateTime @@ -65,12 +67,29 @@ interface CameraVendor { fun createConnectionDelegate(): VendorConnectionDelegate /** - * Returns the device capabilities for this vendor. + * Returns the remote control capabilities for this vendor. * - * Different vendors may support different features (e.g., geo-tagging, time sync, firmware - * version reading, etc.). + * Defines what remote shooting, monitoring, and transfer features are supported. */ - fun getCapabilities(): CameraCapabilities + fun getRemoteControlCapabilities(): RemoteControlCapabilities + + /** + * Returns the background sync capabilities for this vendor. + * + * Defines support for firmware, device name, date/time, geo-tagging, location, pairing, etc. + */ + fun getSyncCapabilities(): SyncCapabilities + + /** + * Returns the name to set on the camera for this paired device (e.g. phone name). + * + * Used when the camera supports displaying or storing the connected device name. + * + * @param deviceNameProvider Provider for the current device name. + * @return The name to write to the camera. + */ + fun getPairedDeviceName(deviceNameProvider: DeviceNameProvider): String = + deviceNameProvider.getDeviceName() /** * Extracts the camera model from a pairing name. @@ -93,20 +112,26 @@ interface CameraVendor { fun getCompanionDeviceFilters(): List> = emptyList() /** - * Returns the name that should be sent to the camera to identify this paired device (the - * phone). + * Creates a remote control delegate for this vendor. * - * @param deviceNameProvider Provider for the phone's device name. - * @return The name to set on the camera. + * @param peripheral The connected BLE peripheral. + * @param camera The camera domain object. + * @return A new instance of [RemoteControlDelegate]. */ - fun getPairedDeviceName(deviceNameProvider: DeviceNameProvider): String = - deviceNameProvider.getDeviceName() + fun createRemoteControlDelegate(peripheral: Peripheral, camera: Camera): RemoteControlDelegate } /** Defines the BLE GATT service and characteristic UUIDs for a camera vendor. */ @OptIn(ExperimentalUuidApi::class) interface CameraGattSpec { + /** Manufacturer data scan filter for BLE advertisements. */ + data class ManufacturerDataFilter( + val manufacturerId: Int, + val data: ByteArray, + val mask: ByteArray? = null, + ) + /** Service UUID(s) used for scanning and filtering camera advertisements. */ val scanFilterServiceUuids: List @@ -114,6 +139,10 @@ interface CameraGattSpec { val scanFilterDeviceNames: List get() = emptyList() + /** Manufacturer data filters used for scanning and filtering camera advertisements. */ + val scanFilterManufacturerData: List + get() = emptyList() + /** Firmware version service UUID, or null if not supported. */ val firmwareServiceUuid: Uuid? @@ -217,32 +246,3 @@ interface CameraProtocol { */ fun getPairingInitData(): ByteArray? = null } - -/** Defines the capabilities supported by a camera vendor. */ -data class CameraCapabilities( - /** Whether the camera supports reading firmware version. */ - val supportsFirmwareVersion: Boolean = false, - - /** Whether the camera supports setting a paired device name. */ - val supportsDeviceName: Boolean = false, - - /** Whether the camera supports date/time synchronization. */ - val supportsDateTimeSync: Boolean = false, - - /** Whether the camera supports enabling/disabling geo-tagging. */ - val supportsGeoTagging: Boolean = false, - - /** Whether the camera supports GPS location synchronization. */ - val supportsLocationSync: Boolean = false, - - /** - * Whether the camera requires vendor-specific pairing initialization. - * - * Some vendors (like Sony) require a specific BLE command to be sent after OS-level bonding to - * complete the pairing process. - */ - val requiresVendorPairing: Boolean = false, - - /** Whether the camera supports reading hardware revision. */ - val supportsHardwareRevision: Boolean = false, -) diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlCapabilities.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlCapabilities.kt new file mode 100644 index 0000000..4adf63f --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlCapabilities.kt @@ -0,0 +1,151 @@ +package dev.sebastiano.camerasync.domain.vendor + +/** + * Defines the comprehensive set of remote control capabilities supported by a camera. + * + * This structured model allows for granular declaration of supported features across different + * functional groups (shooting, monitoring, transfer, etc.). + * + * The capabilities are static declarations of what the vendor implementation *can* support. Runtime + * availability may depend on the active [ShootingConnectionMode]. + */ +data class RemoteControlCapabilities( + val connectionModeSupport: ConnectionModeSupport = ConnectionModeSupport(), + + // --- Status display (typically BLE-only for both vendors) --- + val batteryMonitoring: BatteryMonitoringCapabilities = BatteryMonitoringCapabilities(), + val storageMonitoring: StorageMonitoringCapabilities = StorageMonitoringCapabilities(), + + // --- Shooting control --- + val remoteCapture: RemoteCaptureCapabilities = RemoteCaptureCapabilities(), + val advancedShooting: AdvancedShootingCapabilities = AdvancedShootingCapabilities(), + val videoRecording: VideoRecordingCapabilities = VideoRecordingCapabilities(), + val liveView: LiveViewCapabilities = LiveViewCapabilities(), + val autofocus: AutofocusCapabilities = AutofocusCapabilities(), + val imageControl: ImageControlCapabilities = ImageControlCapabilities(), + + // --- File Transfer --- + val imageBrowsing: ImageBrowsingCapabilities = ImageBrowsingCapabilities(), +) + +/** Defines which connection modes the camera supports for remote control. */ +data class ConnectionModeSupport( + /** Whether remote shooting features are available over BLE only. */ + val bleOnlyShootingSupported: Boolean = false, + /** Whether connecting via Wi-Fi adds additional features (e.g., live view, image transfer). */ + val wifiAddsFeatures: Boolean = false, +) + +/** Capabilities related to battery status monitoring. */ +data class BatteryMonitoringCapabilities( + val supported: Boolean = false, + /** Whether the camera reports battery level for multiple packs (e.g. grip). */ + val supportsMultiplePacks: Boolean = false, + /** Whether the camera reports power source (e.g. USB vs Battery). */ + val supportsPowerSourceDetection: Boolean = false, +) + +/** Capabilities related to storage status monitoring. */ +data class StorageMonitoringCapabilities( + val supported: Boolean = false, + /** Whether the camera reports status for multiple storage slots. */ + val supportsMultipleSlots: Boolean = false, + /** Whether the camera reports remaining recording time for video. */ + val supportsVideoCapacity: Boolean = false, +) + +/** Capabilities related to basic remote shutter control. */ +data class RemoteCaptureCapabilities( + val supported: Boolean = false, + /** Whether Wi-Fi is required for basic shutter control. */ + val requiresWifi: Boolean = false, + /** Whether the camera supports a distinct half-press AF state/command. */ + val supportsHalfPressAF: Boolean = false, + /** Whether the camera supports touch-to-focus (requires coordinate input). */ + val supportsTouchAF: Boolean = false, + /** Whether the camera supports Bulb/Time exposure control. */ + val supportsBulbMode: Boolean = false, + /** Whether the camera supports manual focus commands. */ + val supportsManualFocus: Boolean = false, + /** Whether the camera supports zoom commands. */ + val supportsZoom: Boolean = false, + /** Whether the camera supports AE lock toggle. */ + val supportsAELock: Boolean = false, + /** Whether the camera supports FE lock toggle. */ + val supportsFELock: Boolean = false, + /** Whether the camera supports AWB lock toggle. */ + val supportsAWBLock: Boolean = false, + /** Whether the camera supports triggering custom buttons. */ + val supportsCustomButtons: Boolean = false, +) + +/** Capabilities related to advanced shooting settings (Exposure, Drive, etc.). */ +data class AdvancedShootingCapabilities( + val supported: Boolean = false, + /** Whether these features require Wi-Fi to function. */ + val requiresWifi: Boolean = false, + val supportsExposureModeReading: Boolean = false, + val supportsDriveModeReading: Boolean = false, + val supportsSelfTimer: Boolean = false, + /** Whether the camera supports user-defined modes (U1/U2/U3). */ + val supportsUserModes: Boolean = false, + /** Whether the camera supports Program Shift. */ + val supportsProgramShift: Boolean = false, + val supportsExposureCompensation: Boolean = false, +) + +/** Capabilities related to video recording. */ +data class VideoRecordingCapabilities( + val supported: Boolean = false, + /** Whether Wi-Fi is required to toggle recording. */ + val requiresWifi: Boolean = false, +) + +/** Capabilities related to Live View / Viewfinder. */ +data class LiveViewCapabilities( + val supported: Boolean = false, + /** Whether Wi-Fi is required for live view stream. */ + val requiresWifi: Boolean = true, + /** Whether the camera supports post-capture image review. */ + val supportsPostView: Boolean = false, +) + +/** Capabilities related to Autofocus control. */ +data class AutofocusCapabilities( + val supported: Boolean = false, + val supportsFocusStatusReading: Boolean = false, +) + +/** Capabilities related to Image Control / Picture Profiles. */ +data class ImageControlCapabilities( + val supported: Boolean = false, + /** Whether Wi-Fi is required to manage image controls. */ + val requiresWifi: Boolean = true, + /** Whether the camera supports custom preset slots. */ + val supportsCustomPresets: Boolean = false, + /** Whether the camera supports fine-tuning preset parameters. */ + val supportsParameterAdjustment: Boolean = false, +) + +/** Capabilities related to image browsing and transfer. */ +data class ImageBrowsingCapabilities( + val supported: Boolean = false, + val supportsThumbnails: Boolean = false, + val supportsPreview: Boolean = false, + val supportsFullDownload: Boolean = false, + val supportsExifReading: Boolean = false, + /** Whether the camera can initiate a push transfer to the phone. */ + val supportsPushTransfer: Boolean = false, + val supportsDownloadResume: Boolean = false, +) + +/** Background sync capabilities (firmware, device name, date/time, geo-tagging, location). */ +data class SyncCapabilities( + val supportsFirmwareVersion: Boolean = false, + val supportsDeviceName: Boolean = false, + val supportsDateTimeSync: Boolean = false, + val supportsGeoTagging: Boolean = false, + val supportsLocationSync: Boolean = false, + val requiresVendorPairing: Boolean = false, + val supportsHardwareRevision: Boolean = false, +) diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegate.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegate.kt new file mode 100644 index 0000000..91fb3c4 --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegate.kt @@ -0,0 +1,250 @@ +package dev.sebastiano.camerasync.domain.vendor + +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.CustomButton +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.FocusStatus +import dev.sebastiano.camerasync.domain.model.LiveViewFrame +import dev.sebastiano.camerasync.domain.model.RecordingStatus +import dev.sebastiano.camerasync.domain.model.ShutterStatus +import dev.sebastiano.camerasync.domain.model.StorageInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Defines the contract for vendor-specific remote control implementations. + * + * This delegate handles the actual communication with the camera for shooting and monitoring + * features. It adapts its behavior based on the active [ShootingConnectionMode]. + * + * Implementations should respect the capabilities declared in [RemoteControlCapabilities]. Methods + * corresponding to unsupported capabilities should be no-ops or throw + * [UnsupportedOperationException]. Methods requiring Wi-Fi should handle the case where Wi-Fi is + * not connected (e.g., by throwing [IllegalStateException] or returning empty flows). + */ +interface RemoteControlDelegate { + + /** + * The current connection mode (BLE-only or Full Wi-Fi). + * + * This state flow allows the UI to react to changes in connectivity, enabling/disabling + * features dynamically. + */ + val connectionMode: StateFlow + + // --- Shutter Control (Basic) --- + + /** + * Triggers the shutter release. + * + * In BLE-only mode, this sends the appropriate BLE command. In Full mode, this may use PTP/IP + * or HTTP depending on the vendor. + */ + suspend fun triggerCapture() + + /** + * Starts a Bulb/Time exposure (long exposure). + * + * Relevant for cameras supporting [RemoteCaptureCapabilities.supportsBulbMode]. + */ + suspend fun startBulbExposure() + + /** Stops a Bulb/Time exposure. */ + suspend fun stopBulbExposure() + + // --- Status Monitoring (BLE-only or Full) --- + + /** + * Observes the camera's battery level. + * + * Should work in BLE-only mode for most vendors. + */ + fun observeBatteryLevel(): Flow + + /** + * Observes the camera's storage status (capacity, card presence). + * + * Should work in BLE-only mode for most vendors. + */ + fun observeStorageStatus(): Flow + + /** Observes the camera's current operation mode (Still, Movie, Playback, etc.). */ + fun observeCameraMode(): Flow + + /** Observes the capture status (Idle, Capturing, Countdown). */ + fun observeCaptureStatus(): Flow + + /** + * Observes the current exposure mode (P, A, S, M, etc.). + * + * For Sony, this typically requires Wi-Fi (Full mode). For Ricoh, this works over BLE. + */ + fun observeExposureMode(): Flow + + /** Observes the current drive mode (Single, Continuous, Self-timer). */ + fun observeDriveMode(): Flow + + // --- Wi-Fi Connection Management --- + + /** + * Initiates the Wi-Fi connection process to upgrade from BLE-only to Full mode. + * + * This typically involves: + * 1. Commanding the camera via BLE to enable Wi-Fi. + * 2. Retrieving credentials. + * 3. Connecting the Android device to the camera AP. + * 4. Establishing the data channel (PTP/IP or HTTP). + */ + suspend fun connectWifi() + + /** Disconnects Wi-Fi and downgrades the connection to BLE-only mode. */ + suspend fun disconnectWifi() + + // --- Advanced Shooting Controls (Sony BLE / Wi-Fi) --- + + /** + * Performs a half-press of the shutter button (Autofocus). + * + * Primarily for Sony cameras (BLE FF01 or PTP/IP). + */ + suspend fun halfPressAF() { + throw UnsupportedOperationException("Half-press AF is not supported by this device.") + } + + /** Releases the half-press AF state. */ + suspend fun releaseAF() { + throw UnsupportedOperationException("Release AF is not supported by this device.") + } + + /** + * Adjusts manual focus towards "Near". + * + * @param speed Speed of adjustment (vendor-specific range, typically 0-127). 0 usually means + * "stop". + */ + suspend fun focusNear(speed: Int = 0x20) { + throw UnsupportedOperationException("Manual focus control is not supported by this device.") + } + + /** + * Adjusts manual focus towards "Far". + * + * @param speed Speed of adjustment. + */ + suspend fun focusFar(speed: Int = 0x20) { + throw UnsupportedOperationException("Manual focus control is not supported by this device.") + } + + /** + * Zooms in (Tele). + * + * @param speed Speed of zoom. + */ + suspend fun zoomIn(speed: Int = 0x20) { + throw UnsupportedOperationException("Zoom control is not supported by this device.") + } + + /** + * Zooms out (Wide). + * + * @param speed Speed of zoom. + */ + suspend fun zoomOut(speed: Int = 0x20) { + throw UnsupportedOperationException("Zoom control is not supported by this device.") + } + + /** Presses a custom button on the camera. */ + suspend fun pressCustomButton(button: CustomButton) { + throw UnsupportedOperationException("Custom button press is not supported by this device.") + } + + /** Releases a custom button. */ + suspend fun releaseCustomButton(button: CustomButton) { + throw UnsupportedOperationException( + "Custom button release is not supported by this device." + ) + } + + /** + * Observes the focus status (Locked, Searching, Lost). + * + * Returns null if unsupported by the vendor or current connection mode. + */ + fun observeFocusStatus(): Flow? = null + + /** + * Observes the shutter status (Ready, Active). + * + * Returns null if unsupported. + */ + fun observeShutterStatus(): Flow? = null + + // --- Wi-Fi Only Extensions --- + + /** + * Performs Touch AF at the specified relative coordinates. + * + * @param x Horizontal position (0.0 - 1.0). + * @param y Vertical position (0.0 - 1.0). + */ + suspend fun touchAF(x: Float, y: Float) { + throw UnsupportedOperationException("Touch AF is not supported by this device.") + } + + /** Toggles Auto Exposure Lock. */ + suspend fun toggleAELock() { + throw UnsupportedOperationException("AE Lock is not supported by this device.") + } + + /** Toggles Flash Exposure Lock. */ + suspend fun toggleFELock() { + throw UnsupportedOperationException("FE Lock is not supported by this device.") + } + + /** Toggles Auto White Balance Lock. */ + suspend fun toggleAWBLock() { + throw UnsupportedOperationException("AWB Lock is not supported by this device.") + } + + /** + * Observes the Live View stream. + * + * Only available in Full mode for supported vendors (e.g., Sony). + */ + fun observeLiveView(): Flow? = null + + /** Observes the current zoom level. */ + fun observeZoomLevel(): Flow? = null + + // --- Video Recording --- + + /** Toggles video recording start/stop. */ + suspend fun toggleVideoRecording() { + throw UnsupportedOperationException("Video recording is not supported by this device.") + } + + /** Observes video recording status. */ + fun observeRecordingStatus(): Flow? = null +} + +/** Represents the active connection tier for remote control. */ +enum class ShootingConnectionMode { + /** + * Connected via BLE only. + * + * Base remote control features (shutter, basic monitoring) are available. Power consumption is + * low. + */ + BLE_ONLY, + + /** + * Connected via BLE and Wi-Fi. + * + * Full remote control features (Live View, Image Transfer, advanced settings) are available. + * Power consumption is higher. + */ + FULL, +} diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingScreen.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingScreen.kt new file mode 100644 index 0000000..85e4f97 --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingScreen.kt @@ -0,0 +1,565 @@ +package dev.sebastiano.camerasync.ui.remote + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.sebastiano.camerasync.R +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.FocusStatus +import dev.sebastiano.camerasync.domain.model.StorageInfo +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +private fun batteryDrawableResId(battery: BatteryInfo?): Int = + when { + battery == null -> R.drawable.battery_android_question_24dp + battery.isCharging -> R.drawable.battery_android_bolt_24dp + battery.levelPercentage == 0 -> R.drawable.battery_android_alert_24dp + battery.levelPercentage == 100 -> R.drawable.battery_android_full_24dp + battery.levelPercentage < 17 -> R.drawable.battery_android_1_24dp + battery.levelPercentage < 34 -> R.drawable.battery_android_2_24dp + battery.levelPercentage < 51 -> R.drawable.battery_android_3_24dp + battery.levelPercentage < 67 -> R.drawable.battery_android_4_24dp + battery.levelPercentage < 84 -> R.drawable.battery_android_5_24dp + else -> R.drawable.battery_android_6_24dp + } + +private fun exposureModeDrawableResId(mode: ExposureMode): Int = + when (mode) { + ExposureMode.PROGRAM_AUTO -> R.drawable.mode_program_auto + ExposureMode.APERTURE_PRIORITY -> R.drawable.mode_aperture + ExposureMode.SHUTTER_PRIORITY -> R.drawable.mode_shutter + ExposureMode.MANUAL -> R.drawable.mode_manual + ExposureMode.BULB, + ExposureMode.TIME, + ExposureMode.BULB_TIMER -> R.drawable.bulb_24dp + ExposureMode.AUTO, + ExposureMode.SNAP_FOCUS_PROGRAM, + ExposureMode.UNKNOWN -> R.drawable.mode_auto + } + +private fun driveModeDrawableResId(drive: DriveMode): Int = + when (drive) { + DriveMode.SINGLE_SHOOTING -> R.drawable.drive_single + DriveMode.CONTINUOUS_SHOOTING -> R.drawable.drive_continuous + DriveMode.SELF_TIMER_2S -> R.drawable.drive_timer_2s + DriveMode.SELF_TIMER_10S -> R.drawable.drive_timer_10s + DriveMode.BRACKET -> R.drawable.drive_bracket + DriveMode.INTERVAL, + DriveMode.MULTI_EXPOSURE, + DriveMode.UNKNOWN -> R.drawable.drive_single + } + +private fun focusStatusDrawableResId(status: FocusStatus): Int = + when (status) { + FocusStatus.MANUAL -> R.drawable.focus_manual + FocusStatus.LOCKED -> R.drawable.focus_auto_single + FocusStatus.SEARCHING -> R.drawable.focus_auto_continuous + FocusStatus.LOST -> R.drawable.focus_auto_auto + } + +@Composable +fun RemoteShootingScreen(viewModel: RemoteShootingViewModel, onBackClick: () -> Unit) { + val uiState by viewModel.uiState.collectAsState() + + when (val state = uiState) { + RemoteShootingUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is RemoteShootingUiState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Error: ${state.message}") + Spacer(modifier = Modifier.size(16.dp)) + Button(onClick = onBackClick) { Text(stringResource(R.string.action_go_back)) } + } + } + } + is RemoteShootingUiState.Ready -> { + RemoteShootingContent( + deviceName = state.deviceName, + capabilities = state.capabilities, + delegate = state.delegate, + actionState = state.actionState, + onBackClick = onBackClick, + onTriggerCapture = { viewModel.triggerCapture() }, + onDisconnectWifi = { viewModel.disconnectWifi() }, + onRetryAction = viewModel::retryAction, + onResetRemoteShooting = viewModel::resetRemoteShooting, + onDismissActionError = viewModel::dismissActionError, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RemoteShootingContent( + deviceName: String, + capabilities: RemoteControlCapabilities, + delegate: RemoteControlDelegate, + actionState: RemoteShootingActionState, + onBackClick: () -> Unit, + onTriggerCapture: () -> Unit, + onDisconnectWifi: () -> Unit, + onRetryAction: (RemoteShootingAction) -> Unit, + onResetRemoteShooting: () -> Unit, + onDismissActionError: () -> Unit, +) { + val connectionMode by delegate.connectionMode.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(deviceName) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painterResource(R.drawable.ic_arrow_back_24dp), + contentDescription = stringResource(R.string.content_desc_back), + ) + } + }, + ) + } + ) { paddingValues -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), // Make scrollable + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 1. Connection Banner + ConnectionBanner( + mode = connectionMode, + capabilities = capabilities, + onDisconnectWifi = onDisconnectWifi, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + RemoteShootingActionBanner( + actionState = actionState, + onRetryAction = onRetryAction, + onResetRemoteShooting = onResetRemoteShooting, + onDismissActionError = onDismissActionError, + ) + + if (actionState !is RemoteShootingActionState.Idle) { + Spacer(modifier = Modifier.size(16.dp)) + } + + // 2. Status Bar (Battery, Storage, Mode) + StatusBar(capabilities, delegate) + + Spacer(modifier = Modifier.size(16.dp)) + + // 3. Live View (if supported & connected) + if (capabilities.liveView.supported && connectionMode == ShootingConnectionMode.FULL) { + LiveViewPanel(capabilities) + Spacer(modifier = Modifier.size(16.dp)) + } + + // 4. Capture Button (Always visible if supported) + if (capabilities.remoteCapture.supported) { + CaptureButton(capabilities, onCapture = onTriggerCapture) + Spacer(modifier = Modifier.size(16.dp)) + } + + // 5. Focus Status (if supported - works over BLE independently) + if ( + capabilities.autofocus.supported && + capabilities.autofocus.supportsFocusStatusReading + ) { + FocusStatusWidget(delegate) + Spacer(modifier = Modifier.size(16.dp)) + } + + // 6. Advanced Shooting Controls (if supported) + if (capabilities.advancedShooting.supported) { + // Check if current mode supports these controls + val canShowControls = + !capabilities.advancedShooting.requiresWifi || + connectionMode == ShootingConnectionMode.FULL + + if (canShowControls) { + AdvancedShootingControls(capabilities, delegate) + Spacer(modifier = Modifier.size(16.dp)) + } + } + + // 7. Image Control (Ricoh specific) + if ( + capabilities.imageControl.supported && connectionMode == ShootingConnectionMode.FULL + ) { + ImageControlPanel() + } + } + } +} + +@Composable +internal fun RemoteShootingActionBanner( + actionState: RemoteShootingActionState, + onRetryAction: (RemoteShootingAction) -> Unit, + onResetRemoteShooting: () -> Unit, + onDismissActionError: () -> Unit, +) { + when (actionState) { + is RemoteShootingActionState.Error -> { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = actionState.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.size(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (actionState.canRetry) { + Button(onClick = { onRetryAction(actionState.action) }) { + Text(stringResource(R.string.action_retry)) + } + } + if (actionState.canReset) { + OutlinedButton(onClick = onResetRemoteShooting) { + Text(stringResource(R.string.action_reset_remote_shooting)) + } + } + TextButton(onClick = onDismissActionError) { + Text(stringResource(R.string.action_ok)) + } + } + } + } + } + is RemoteShootingActionState.InProgress -> { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.remote_action_in_progress), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.size(8.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } + RemoteShootingActionState.Idle -> Unit + } +} + +@Composable +fun ConnectionBanner( + mode: ShootingConnectionMode, + capabilities: RemoteControlCapabilities, + onDisconnectWifi: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = + when (mode) { + ShootingConnectionMode.BLE_ONLY -> + stringResource(R.string.remote_connection_ble) + ShootingConnectionMode.FULL -> + stringResource(R.string.remote_connection_wifi) + }, + style = MaterialTheme.typography.titleMedium, + ) + + // Full mode (Wi‑Fi) switch is not implemented yet; do not show Connect Wi‑Fi button. + if (capabilities.connectionModeSupport.wifiAddsFeatures) { + Spacer(modifier = Modifier.size(8.dp)) + if (mode == ShootingConnectionMode.BLE_ONLY) { + Text( + stringResource(R.string.remote_wifi_coming_soon), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Button(onClick = onDisconnectWifi) { + Text(stringResource(R.string.remote_disconnect_wifi)) + } + } + } + } + } +} + +@Composable +fun StatusBar(capabilities: RemoteControlCapabilities, delegate: RemoteControlDelegate) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (capabilities.batteryMonitoring.supported) { + val batteryFlow = + remember(delegate) { + delegate + .observeBatteryLevel() + .map { it } + .catch { emit(null) } + } + val battery by batteryFlow.collectAsState(initial = null) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(batteryDrawableResId(battery)), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + if (battery != null) { + stringResource( + R.string.remote_status_battery, + battery!!.levelPercentage, + ) + } else { + stringResource(R.string.remote_status_battery_unknown) + } + ) + } + } + if (capabilities.storageMonitoring.supported) { + val storageFlow = + remember(delegate) { + delegate + .observeStorageStatus() + .map { it } + .catch { emit(null) } + } + val storage by storageFlow.collectAsState(initial = null) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(R.drawable.sd_card_24dp), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + when { + storage?.isPresent == true -> + stringResource( + R.string.remote_status_storage, + storage?.remainingShots?.toString() ?: "--", + ) + else -> stringResource(R.string.remote_status_storage_no_card) + } + ) + } + } + } + } +} + +@Composable +fun LiveViewPanel(capabilities: RemoteControlCapabilities) { + Card( + modifier = Modifier.fillMaxWidth().size(200.dp) // Aspect ratio placeholder + ) { + // Live view stream rendering would go here + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringResource(R.string.remote_live_view_placeholder)) + if (capabilities.remoteCapture.supportsTouchAF) { + Text( + stringResource(R.string.remote_live_view_touch_af), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +@Composable +fun CaptureButton(capabilities: RemoteControlCapabilities, onCapture: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton(onClick = onCapture, modifier = Modifier.size(88.dp)) { + Icon( + painter = painterResource(R.drawable.ic_photo_camera_48dp), + contentDescription = stringResource(R.string.content_desc_capture), + modifier = Modifier.fillMaxSize(), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + stringResource(R.string.remote_capture_button), + style = MaterialTheme.typography.labelMedium, + ) + if (capabilities.remoteCapture.supportsHalfPressAF) { + Text( + stringResource(R.string.remote_half_press_supported), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun AdvancedShootingControls( + capabilities: RemoteControlCapabilities, + delegate: RemoteControlDelegate, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.remote_shooting_settings), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (capabilities.advancedShooting.supportsExposureModeReading) { + val exposureModeFlow = + remember(delegate) { + delegate.observeExposureMode().catch { emit(ExposureMode.UNKNOWN) } + } + val mode by exposureModeFlow.collectAsState(initial = ExposureMode.UNKNOWN) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(exposureModeDrawableResId(mode)), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Column { + Text( + stringResource(R.string.remote_label_mode), + style = MaterialTheme.typography.labelMedium, + ) + Text(mode.toString(), style = MaterialTheme.typography.bodyLarge) + } + } + } + if (capabilities.advancedShooting.supportsDriveModeReading) { + val driveModeFlow = + remember(delegate) { + delegate.observeDriveMode().catch { emit(DriveMode.UNKNOWN) } + } + val drive by driveModeFlow.collectAsState(initial = DriveMode.UNKNOWN) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(driveModeDrawableResId(drive)), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Column { + Text( + stringResource(R.string.remote_label_drive), + style = MaterialTheme.typography.labelMedium, + ) + Text(drive.toString(), style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} + +@Composable +fun FocusStatusWidget(delegate: RemoteControlDelegate) { + val focusFlow = + remember(delegate) { delegate.observeFocusStatus()?.catch { emit(FocusStatus.LOST) } } + if (focusFlow != null) { + val focusStatus by focusFlow.collectAsState(initial = FocusStatus.LOST) + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(focusStatusDrawableResId(focusStatus)), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Column { + Text( + stringResource(R.string.remote_label_focus), + style = MaterialTheme.typography.labelMedium, + ) + Text(focusStatus.toString(), style = MaterialTheme.typography.bodyLarge) + } + } + } + } +} + +@Composable +fun ImageControlPanel() { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.remote_image_control), + style = MaterialTheme.typography.titleMedium, + ) + Text( + stringResource(R.string.remote_image_control_standard), + style = MaterialTheme.typography.bodyLarge, + ) + // Preset list, parameters + } + } +} diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModel.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModel.kt new file mode 100644 index 0000000..e4fa8a2 --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModel.kt @@ -0,0 +1,244 @@ +package dev.sebastiano.camerasync.ui.remote + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.juul.khronicle.Log +import dev.sebastiano.camerasync.devicesync.DeviceConnectionManager +import dev.sebastiano.camerasync.devicesync.IntentFactory +import dev.sebastiano.camerasync.domain.repository.CameraConnection +import dev.sebastiano.camerasync.domain.repository.PairedDevicesRepository +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * ViewModel for the Remote Shooting screen. + * + * Manages the connection to the camera's remote control delegate and exposes capabilities and state + * for the UI. + */ +@Inject +class RemoteShootingViewModel( + private val deviceConnectionManager: DeviceConnectionManager, + private val pairedDevicesRepository: PairedDevicesRepository, + private val intentFactory: IntentFactory, + private val context: Context, + private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val _uiState = MutableStateFlow(RemoteShootingUiState.Loading) + val uiState: StateFlow = _uiState + + private var loadJob: Job? = null + private var currentMacAddress: String? = null + + /** + * Loads the device and observes connection changes for [macAddress]. + * + * When the BLE connection for this device is dropped and re-established, the manager gets a new + * [CameraConnection] (and thus a new [RemoteControlDelegate] with a fresh peripheral). This + * method subscribes to [DeviceConnectionManager.connectionFlow] so the UI state is refreshed + * whenever the connection is added, removed, or replaced—avoiding a stale delegate after + * reconnect. + */ + fun loadDevice(macAddress: String) { + loadJob?.cancel() + currentMacAddress = macAddress + _uiState.value = RemoteShootingUiState.Loading + loadJob = + viewModelScope.launch(ioDispatcher) { + deviceConnectionManager + .connectionFlow(macAddress) + .onEach { connection -> applyConnectionState(macAddress, connection) } + .catch { e -> + Log.warn(tag = TAG, throwable = e) { + "Error observing connection for $macAddress" + } + _uiState.value = + RemoteShootingUiState.Error( + "Failed to load device. Please go back and try again." + ) + } + .launchIn(this) + } + } + + /** + * Updates UI state from the current connection for [macAddress]. Called on initial load and + * whenever [DeviceConnectionManager.connectionFlow] emits (e.g. on reconnect). + */ + private suspend fun applyConnectionState(macAddress: String, connection: CameraConnection?) { + if (connection == null) { + _uiState.value = + RemoteShootingUiState.Error( + "Device not connected via BLE. Please connect from the main list first." + ) + return + } + val device = pairedDevicesRepository.getDevice(macAddress) + if (device == null) { + _uiState.value = RemoteShootingUiState.Error("Device not found") + return + } + val delegate = connection.getRemoteControlDelegate() + if (delegate == null) { + _uiState.value = + RemoteShootingUiState.Error("Remote control not supported for this device") + return + } + val capabilities = connection.camera.vendor.getRemoteControlCapabilities() + _uiState.value = + RemoteShootingUiState.Ready( + deviceName = device.name ?: "Camera", + capabilities = capabilities, + delegate = delegate, + actionState = RemoteShootingActionState.Idle, + ) + } + + /** + * Triggers remote capture. Exceptions (e.g. BLE disconnect) are caught and logged so they do + * not crash the app. + */ + fun triggerCapture() { + runAction(RemoteShootingAction.TriggerCapture) { delegate -> delegate.triggerCapture() } + } + + /** + * Disconnects Wi‑Fi remote session. Exceptions (e.g. BLE disconnect) are caught and logged so + * they do not crash the app. + */ + fun disconnectWifi() { + runAction(RemoteShootingAction.DisconnectWifi) { delegate -> delegate.disconnectWifi() } + } + + fun retryAction(action: RemoteShootingAction) { + when (action) { + RemoteShootingAction.TriggerCapture -> triggerCapture() + RemoteShootingAction.DisconnectWifi -> disconnectWifi() + RemoteShootingAction.ResetRemoteShooting -> resetRemoteShooting() + } + } + + fun resetRemoteShooting() { + runAction(RemoteShootingAction.ResetRemoteShooting) { + val macAddress = currentMacAddress ?: error("No device loaded for reset") + val device = pairedDevicesRepository.getDevice(macAddress) + if (device == null) { + error("Device not found for reset") + } + if (!device.isEnabled) { + error("Device is disabled. Enable sync to reset remote shooting.") + } + val intent = intentFactory.createRefreshDeviceIntent(context, macAddress) + ContextCompat.startForegroundService(context, intent) + } + } + + fun dismissActionError() { + updateActionState(RemoteShootingActionState.Idle) + } + + private fun runAction( + action: RemoteShootingAction, + block: suspend (RemoteControlDelegate) -> Unit, + ) { + val delegate = (_uiState.value as? RemoteShootingUiState.Ready)?.delegate ?: return + updateActionState(RemoteShootingActionState.InProgress(action)) + viewModelScope.launch(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + block(delegate) + updateActionState(RemoteShootingActionState.Idle) + } catch (e: CancellationException) { + updateActionState(RemoteShootingActionState.Idle) + throw e + } catch (e: Exception) { + Log.warn(tag = TAG, throwable = e) { "Remote shooting action failed: $action" } + updateActionState(buildActionError(action, e)) + } + } + } + + private fun updateActionState(actionState: RemoteShootingActionState) { + val current = _uiState.value + if (current is RemoteShootingUiState.Ready) { + _uiState.value = current.copy(actionState = actionState) + } + } + + private fun buildActionError( + action: RemoteShootingAction, + error: Throwable, + ): RemoteShootingActionState.Error { + val message = + when (error) { + is UnsupportedOperationException -> error.message ?: "This action is not supported." + is IllegalStateException -> error.message ?: "This action is not available." + else -> "Action failed. Please try again." + } + val canRetry = + action != RemoteShootingAction.ResetRemoteShooting && + error !is UnsupportedOperationException && + error !is IllegalStateException + val canReset = + action != RemoteShootingAction.ResetRemoteShooting && + error !is UnsupportedOperationException && + error !is IllegalStateException + return RemoteShootingActionState.Error( + action = action, + message = message, + canRetry = canRetry, + canReset = canReset, + ) + } + + companion object { + private const val TAG = "RemoteShootingViewModel" + } +} + +sealed interface RemoteShootingUiState { + data object Loading : RemoteShootingUiState + + data class Error(val message: String) : RemoteShootingUiState + + data class Ready( + val deviceName: String, + val capabilities: RemoteControlCapabilities, + val delegate: RemoteControlDelegate, + val actionState: RemoteShootingActionState, + ) : RemoteShootingUiState +} + +sealed interface RemoteShootingActionState { + data object Idle : RemoteShootingActionState + + data class InProgress(val action: RemoteShootingAction) : RemoteShootingActionState + + data class Error( + val action: RemoteShootingAction, + val message: String, + val canRetry: Boolean, + val canReset: Boolean, + ) : RemoteShootingActionState +} + +sealed interface RemoteShootingAction { + data object TriggerCapture : RemoteShootingAction + + data object DisconnectWifi : RemoteShootingAction + + data object ResetRemoteShooting : RemoteShootingAction +} diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendor.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendor.kt index 422d05a..8908461 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendor.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendor.kt @@ -3,18 +3,28 @@ package dev.sebastiano.camerasync.vendors.ricoh import android.bluetooth.le.ScanFilter import android.companion.BluetoothLeDeviceFilter import android.companion.DeviceFilter -import android.os.ParcelUuid -import dev.sebastiano.camerasync.domain.vendor.CameraCapabilities +import com.juul.kable.Peripheral +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.vendor.AdvancedShootingCapabilities +import dev.sebastiano.camerasync.domain.vendor.BatteryMonitoringCapabilities import dev.sebastiano.camerasync.domain.vendor.CameraGattSpec import dev.sebastiano.camerasync.domain.vendor.CameraProtocol import dev.sebastiano.camerasync.domain.vendor.CameraVendor +import dev.sebastiano.camerasync.domain.vendor.ConnectionModeSupport import dev.sebastiano.camerasync.domain.vendor.DefaultConnectionDelegate +import dev.sebastiano.camerasync.domain.vendor.ImageBrowsingCapabilities +import dev.sebastiano.camerasync.domain.vendor.ImageControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteCaptureCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.StorageMonitoringCapabilities +import dev.sebastiano.camerasync.domain.vendor.SyncCapabilities import dev.sebastiano.camerasync.domain.vendor.VendorConnectionDelegate +import dev.sebastiano.camerasync.domain.vendor.VideoRecordingCapabilities import dev.sebastiano.camerasync.util.DeviceNameProvider import java.util.regex.Pattern import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -import kotlin.uuid.toJavaUuid /** * Ricoh camera vendor implementation. @@ -35,14 +45,24 @@ object RicohCameraVendor : CameraVendor { override val protocol: CameraProtocol = RicohProtocol + private val ricohServiceUuids: Set = + setOf( + RicohGattSpec.Firmware.SERVICE_UUID, + RicohGattSpec.DateTime.SERVICE_UUID, + RicohGattSpec.Shooting.SERVICE_UUID, + RicohGattSpec.WlanControl.SERVICE_UUID, + RicohGattSpec.DeviceName.SERVICE_UUID, + RicohGattSpec.CameraState.SERVICE_UUID, + RicohGattSpec.Location.SERVICE_UUID, + ) + override fun recognizesDevice( deviceName: String?, serviceUuids: List, manufacturerData: Map, ): Boolean { - // Ricoh cameras advertise a specific service UUID - val hasRicohService = - serviceUuids.any { uuid -> RicohGattSpec.scanFilterServiceUuids.contains(uuid) } + val manufacturerBytes = manufacturerData[RicohGattSpec.RICOH_MANUFACTURER_ID] + val hasRicohManufacturerData = manufacturerBytes?.firstOrNull() == 0xDA.toByte() // Additional check: device name typically starts with "GR" or "RICOH" val hasRicohName = @@ -52,14 +72,101 @@ object RicohCameraVendor : CameraVendor { } } ?: false - // Accept device if it has the Ricoh service UUID or a recognized name - return hasRicohService || hasRicohName + val hasRicohService = serviceUuids.any { uuid -> uuid in ricohServiceUuids } + + // Accept device if it has Ricoh manufacturer data, a recognized name, or Ricoh services + return hasRicohManufacturerData || hasRicohName || hasRicohService + } + + override fun parseAdvertisementMetadata( + manufacturerData: Map + ): Map { + val payload = manufacturerData[RicohGattSpec.RICOH_MANUFACTURER_ID] ?: return emptyMap() + if (payload.isEmpty() || payload[0] != 0xDA.toByte()) return emptyMap() + + val metadata = mutableMapOf() + // Format from def_adv.decrypted.yaml: [0xDA][Type][Len][Data]... + var index = 1 + while (index + 1 < payload.size) { + val type = payload[index].toInt() and 0xFF + val length = payload[index + 1].toInt() and 0xFF + val dataStart = index + 2 + val dataEnd = (dataStart + length).coerceAtMost(payload.size) + if (dataStart >= payload.size) break + when (type) { + 0x01 -> + metadata["modelCode"] = payload.getOrNull(dataStart)?.toInt()?.and(0xFF) ?: -1 + 0x02 -> + if (dataEnd - dataStart >= 4) { + val serial = + payload.copyOfRange(dataStart, dataStart + 4).joinToString("") { + "%02x".format(it.toInt() and 0xFF) + } + metadata["serial"] = serial + } + 0x03 -> + metadata["cameraPower"] = payload.getOrNull(dataStart)?.toInt()?.and(0xFF) ?: -1 + } + index = dataEnd + } + + return metadata } override fun createConnectionDelegate(): VendorConnectionDelegate = DefaultConnectionDelegate() - override fun getCapabilities(): CameraCapabilities { - return CameraCapabilities( + override fun createRemoteControlDelegate( + peripheral: Peripheral, + camera: Camera, + ): RemoteControlDelegate = RicohRemoteControlDelegate(peripheral, camera) + + override fun getRemoteControlCapabilities(): RemoteControlCapabilities { + return RemoteControlCapabilities( + connectionModeSupport = + ConnectionModeSupport(bleOnlyShootingSupported = true, wifiAddsFeatures = true), + batteryMonitoring = BatteryMonitoringCapabilities(supported = true), + storageMonitoring = StorageMonitoringCapabilities(supported = true), + remoteCapture = + RemoteCaptureCapabilities( + supported = true, + requiresWifi = false, + supportsBulbMode = true, + ), + advancedShooting = + AdvancedShootingCapabilities( + supported = true, + requiresWifi = false, + supportsExposureModeReading = true, + supportsDriveModeReading = true, + supportsSelfTimer = true, + supportsUserModes = true, + ), + videoRecording = + VideoRecordingCapabilities( + supported = false, // Not implemented yet + requiresWifi = false, + ), + imageControl = + ImageControlCapabilities( + supported = true, + requiresWifi = true, + supportsCustomPresets = true, + supportsParameterAdjustment = true, + ), + imageBrowsing = + ImageBrowsingCapabilities( + supported = true, + supportsThumbnails = true, + supportsPreview = true, + supportsFullDownload = true, + supportsExifReading = true, + supportsPushTransfer = true, + ), + ) + } + + override fun getSyncCapabilities(): SyncCapabilities { + return SyncCapabilities( supportsFirmwareVersion = true, supportsDeviceName = true, supportsDateTimeSync = true, @@ -106,12 +213,14 @@ object RicohCameraVendor : CameraVendor { } override fun getCompanionDeviceFilters(): List> { - val serviceFilter = + val manufacturerFilter = BluetoothLeDeviceFilter.Builder() .setScanFilter( ScanFilter.Builder() - .setServiceUuid( - ParcelUuid(RicohGattSpec.SCAN_FILTER_SERVICE_UUID.toJavaUuid()) + .setManufacturerData( + RicohGattSpec.RICOH_MANUFACTURER_ID, + byteArrayOf(0xDA.toByte()), + byteArrayOf(0xFF.toByte()), ) .build() ) @@ -122,7 +231,7 @@ object RicohCameraVendor : CameraVendor { .setNamePattern(Pattern.compile("(GR|RICOH).*")) .build() - return listOf(serviceFilter, nameFilter) + return listOf(manufacturerFilter, nameFilter) } override fun getPairedDeviceName(deviceNameProvider: DeviceNameProvider): String = diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpec.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpec.kt index c80afc1..dbfe626 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpec.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpec.kt @@ -15,42 +15,118 @@ import kotlin.uuid.Uuid @OptIn(ExperimentalUuidApi::class) object RicohGattSpec : CameraGattSpec { - /** Service UUID used for scanning and filtering Ricoh camera advertisements. */ - val SCAN_FILTER_SERVICE_UUID: Uuid = Uuid.parse("84A0DD62-E8AA-4D0F-91DB-819B6724C69E") + /** Manufacturer ID for Ricoh advertisement data (Bluetooth SIG Company ID 0x065F). */ + const val RICOH_MANUFACTURER_ID: Int = 0x065F - override val scanFilterServiceUuids: List = listOf(SCAN_FILTER_SERVICE_UUID) + /** Manufacturer data prefix for Ricoh cameras (0xDA). */ + private val MANUFACTURER_DATA_PREFIX: ByteArray = byteArrayOf(0xDA.toByte()) + + /** Mask for matching the manufacturer data prefix. */ + private val MANUFACTURER_DATA_MASK: ByteArray = byteArrayOf(0xFF.toByte()) + + override val scanFilterServiceUuids: List = emptyList() override val scanFilterDeviceNames: List = listOf("GR", "RICOH") - /** Firmware version service and characteristic. */ + override val scanFilterManufacturerData: List = + listOf( + CameraGattSpec.ManufacturerDataFilter( + manufacturerId = RICOH_MANUFACTURER_ID, + data = MANUFACTURER_DATA_PREFIX, + mask = MANUFACTURER_DATA_MASK, + ) + ) + + /** Firmware version service (Camera Information Service). */ object Firmware { val SERVICE_UUID: Uuid = Uuid.parse("9a5ed1c5-74cc-4c50-b5b6-66a48e7ccff1") val VERSION_CHARACTERISTIC_UUID: Uuid = Uuid.parse("b4eb8905-7411-40a6-a367-2834c2157ea7") } + /** Date/time and geo-tagging (same service as CameraState). */ + object DateTime { + val SERVICE_UUID: Uuid = Uuid.parse("4b445988-caa0-4dd3-941d-37b4f52aca86") + val DATE_TIME_CHARACTERISTIC_UUID: Uuid = Uuid.parse("fa46bbdd-8a8f-4796-8cf3-aa58949b130a") + val GEO_TAGGING_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("a36afdcf-6b67-4046-9be7-28fb67dbc071") + } + override val firmwareServiceUuid: Uuid = Firmware.SERVICE_UUID override val firmwareVersionCharacteristicUuid: Uuid = Firmware.VERSION_CHARACTERISTIC_UUID + override val dateTimeServiceUuid: Uuid = DateTime.SERVICE_UUID + override val dateTimeCharacteristicUuid: Uuid = DateTime.DATE_TIME_CHARACTERISTIC_UUID + override val geoTaggingCharacteristicUuid: Uuid = DateTime.GEO_TAGGING_CHARACTERISTIC_UUID + + /** + * Shooting service (dm-zharov Operation Request). Service 9F00F387; remote shutter uses + * OPERATION_REQUEST_CHARACTERISTIC_UUID (559644B8). + */ + object Shooting { + val SERVICE_UUID: Uuid = Uuid.parse("9f00f387-8345-4bbc-8b92-b87b52e3091a") + /** Shooting Mode (P/Av/Tv/etc). */ + val SHOOTING_MODE_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("a3c51525-de3e-4777-a1c2-699e28736fcf") + /** Capture Mode (still/movie). */ + val CAPTURE_MODE_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("78009238-ac3d-4370-9b6f-c9ce2f4e3ca8") + /** Drive Mode (0-65 enum). */ + val DRIVE_MODE_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("b29e6de3-1aec-48c1-9d05-02cea57ce664") + /** Capture Status. */ + val CAPTURE_STATUS_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("b5589c08-b5fd-46f5-be7d-ab1b8c074caa") + /** Operation Request — Write. 2 bytes: [OperationCode, Parameter]. See RicohProtocol. */ + val OPERATION_REQUEST_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("559644b8-e0bc-4011-929b-5cf9199851e7") + } + + /** WLAN Control (dm-zharov). Network Type 0=OFF, 1=AP mode. */ + object WlanControl { + val SERVICE_UUID: Uuid = Uuid.parse("f37f568f-9071-445d-a938-5441f2e82399") + val NETWORK_TYPE_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("9111cdd0-9f01-45c4-a2d4-e09e8fb0424d") + } - /** Paired device name service and characteristic. */ + /** Paired device name (Bluetooth Control Command service). Used by setPairedDeviceName. */ object DeviceName { val SERVICE_UUID: Uuid = Uuid.parse("0f291746-0c80-4726-87a7-3c501fd3b4b6") + /** Paired device name (utf8). */ val NAME_CHARACTERISTIC_UUID: Uuid = Uuid.parse("fe3a32f8-a189-42de-a391-bc81ae4daa76") } override val deviceNameServiceUuid: Uuid = DeviceName.SERVICE_UUID override val deviceNameCharacteristicUuid: Uuid = DeviceName.NAME_CHARACTERISTIC_UUID - /** Date/time and geo-tagging service and characteristics. */ - object DateTime { + /** Camera Service and characteristics (state, power, storage, etc.). */ + object CameraState { val SERVICE_UUID: Uuid = Uuid.parse("4b445988-caa0-4dd3-941d-37b4f52aca86") - val DATE_TIME_CHARACTERISTIC_UUID: Uuid = Uuid.parse("fa46bbdd-8a8f-4796-8cf3-aa58949b130a") - val GEO_TAGGING_CHARACTERISTIC_UUID: Uuid = - Uuid.parse("a36afdcf-6b67-4046-9be7-28fb67dbc071") - } - override val dateTimeServiceUuid: Uuid = DateTime.SERVICE_UUID - override val dateTimeCharacteristicUuid: Uuid = DateTime.DATE_TIME_CHARACTERISTIC_UUID - override val geoTaggingCharacteristicUuid: Uuid = DateTime.GEO_TAGGING_CHARACTERISTIC_UUID + // Operation Mode (Capture, Playback, etc.) + val OPERATION_MODE_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("1452335a-ec7f-4877-b8ab-0f72e18bb295") + + // Storage Info List - Primary + val STORAGE_INFO_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("a0c10148-8865-4470-9631-8f36d79a41a5") + + /** Battery Level + Power Source. */ + val BATTERY_LEVEL_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("875fc41d-4980-434c-a653-fd4a4d4410c4") + + /** File Transfer List. */ + val FILE_TRANSFER_LIST_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("d9ae1c06-447d-4dea-8b7d-fc8b19c2cdae") + + /** Power Off During File Transfer (behavior + resize). */ + val POWER_OFF_DURING_TRANSFER_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("bd6725fc-5d16-496a-a48a-f784594c8ecb") + + /** Camera Power (dm-zharov): 0=Off, 1=On, 2=Sleep. Write/Read/Notify. */ + val CAMERA_POWER_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("b58ce84c-0666-4de9-bec8-2d27b27b3211") + + // Note: Drive Mode, Capture Mode, and Capture Status are in the Shooting service. + } /** Location sync service and characteristic. */ object Location { diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocol.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocol.kt index f1d509a..ed1b957 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocol.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocol.kt @@ -1,6 +1,14 @@ package dev.sebastiano.camerasync.vendors.ricoh +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.BatteryPosition +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode import dev.sebastiano.camerasync.domain.model.GpsLocation +import dev.sebastiano.camerasync.domain.model.PowerSource +import dev.sebastiano.camerasync.domain.model.StorageInfo import dev.sebastiano.camerasync.domain.vendor.CameraProtocol import java.nio.ByteBuffer import java.nio.ByteOrder @@ -20,6 +28,46 @@ object RicohProtocol : CameraProtocol { /** Size of the encoded location data in bytes. */ const val LOCATION_SIZE = 32 + // --- Operation Request (Shooting service 9F00F387, characteristic 559644B8) --- + // Per dm-zharov/ricoh-gr-bluetooth-api: 2 bytes [OperationCode, Parameter]. + /** Operation Request: NOP. */ + const val OP_REQ_NOP = 0 + /** Operation Request: Start Shooting/Recording. */ + const val OP_REQ_START = 1 + /** Operation Request: Stop Shooting/Recording. */ + const val OP_REQ_STOP = 2 + /** Operation Request parameter: No AF. */ + const val OP_REQ_PARAM_NO_AF = 0 + /** Operation Request parameter: AF. */ + const val OP_REQ_PARAM_AF = 1 + /** Operation Request parameter: Green Button Function. */ + const val OP_REQ_PARAM_GREEN_BUTTON = 2 + + /** + * Encodes an Operation Request payload for the Shooting service characteristic (559644B8). + * + * @param operationCode One of [OP_REQ_NOP], [OP_REQ_START], [OP_REQ_STOP]. + * @param parameter One of [OP_REQ_PARAM_NO_AF], [OP_REQ_PARAM_AF], [OP_REQ_PARAM_GREEN_BUTTON]. + */ + fun encodeOperationRequest(operationCode: Int, parameter: Int): ByteArray = + byteArrayOf(operationCode.toByte(), parameter.toByte()) + + // --- Legacy: Command characteristic (A3C51525) single-byte codes --- + // Kept for reference; remote shutter uses Operation Request (encodeOperationRequest) per + // dm-zharov spec. Command char is still used for drive mode notifications. + /** @suppress Legacy single-byte shutter press (use Operation Request in new code). */ + const val RC_SHUTTER_PRESS = 0x01 + /** @suppress Legacy single-byte shutter release (use Operation Request in new code). */ + const val RC_SHUTTER_RELEASE = 0x00 + + /** + * Encodes a single-byte command for the Command characteristic (A3C51525). Prefer + * [encodeOperationRequest] for remote shutter per dm-zharov spec. + * + * @param code One of [RC_SHUTTER_PRESS], [RC_SHUTTER_RELEASE]. + */ + fun encodeRemoteControlCommand(code: Int): ByteArray = byteArrayOf(code.toByte()) + /** * Encodes a date/time value to the Ricoh camera format. * @@ -147,6 +195,172 @@ object RicohProtocol : CameraProtocol { return bytes.first() == 1.toByte() } + /** Decodes battery information from the 875FC41D characteristic. */ + fun decodeBatteryInfo(bytes: ByteArray): BatteryInfo { + if (bytes.isEmpty()) { + return BatteryInfo( + 0, + position = BatteryPosition.UNKNOWN, + powerSource = PowerSource.UNKNOWN, + ) + } + val level = (bytes[0].toInt() and 0xFF).coerceIn(0, 100) + val powerSource = + when (bytes.getOrNull(1)?.toInt() ?: -1) { + 0 -> PowerSource.BATTERY + 1 -> PowerSource.AC_ADAPTER + else -> PowerSource.UNKNOWN + } + return BatteryInfo( + levelPercentage = level, + isCharging = false, // Ricoh BLE doesn't seem to report charging status directly here + powerSource = powerSource, + position = BatteryPosition.INTERNAL, + ) + } + + /** + * Decodes storage information from the eOa notification. + * + * Format: List of storage entries. We usually care about the first one (internal or SD). The + * exact binary format is complex (TLV or struct), but based on observation: It's often a status + * byte followed by remaining shots (int). + */ + fun decodeStorageInfo(bytes: ByteArray): StorageInfo { + // Simplified decoding based on common patterns. Real implementation might need more reverse + // engineering. + // Assuming: [Status, RemainingShots (4 bytes LE)] + if (bytes.size < 5) return StorageInfo(isPresent = false) + + // Status byte interpretation needs verification. Assuming non-zero is present/ready. + val status = bytes[0].toInt() and 0xFF + val isPresent = status != 0 + + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + buffer.position(1) + val remainingShots = buffer.int + + return StorageInfo( + slot = 1, + isPresent = isPresent, + remainingShots = remainingShots, + isFull = remainingShots == 0, // heuristic + ) + } + + /** + * Decodes capture status from tPa notification. + * + * Format (dm-zharov): [CapturingStatus, CountdownStatus, ...] Capturing: 0 = Idle, 1 = + * Capturing. Countdown: 0 = None, 1 = Countdown. + */ + fun decodeCaptureStatus(bytes: ByteArray): CaptureStatus { + if (bytes.size < 2) return CaptureStatus.Idle + + val capturing = bytes[0].toInt() and 0xFF + val countdown = bytes[1].toInt() and 0xFF + + return when { + countdown > 0 -> + CaptureStatus.Countdown( + secondsRemaining = -1 + ) // Specific seconds not provided in simple status + capturing > 0 -> CaptureStatus.Capturing + else -> CaptureStatus.Idle + } + } + + /** + * Decodes exposure mode from a single-byte characteristic (e.g. EXPOSURE_MODE_CHARACTERISTIC). + * + * Format: 1 byte enum (P=0, Av=1, Tv=2, M=3, B=4, BT=5, T=6, SFP=7). + */ + fun decodeExposureMode(bytes: ByteArray): ExposureMode { + if (bytes.isEmpty()) return ExposureMode.UNKNOWN + return exposureModeFromByte(bytes[0].toInt() and 0xFF) + } + + /** + * Decodes shooting mode from uPa notification. + * + * Format: [ShootingMode (Still=0/Movie=1), ExposureMode]. Unknown mode bytes are mapped to + * [CameraMode.UNKNOWN]; behavior is aligned with + * [RicohRemoteControlDelegate.observeCameraMode]. + */ + fun decodeShootingMode(bytes: ByteArray): Pair { + if (bytes.size < 2) return Pair(CameraMode.UNKNOWN, ExposureMode.UNKNOWN) + + val modeByte = bytes[0].toInt() and 0xFF + val cameraMode = + when (modeByte) { + 0 -> CameraMode.STILL_IMAGE + 1 -> CameraMode.MOVIE + else -> CameraMode.UNKNOWN + } + + val exposureMode = exposureModeFromByte(bytes[1].toInt() and 0xFF) + return Pair(cameraMode, exposureMode) + } + + private fun exposureModeFromByte(exposureByte: Int): ExposureMode = + when (exposureByte) { + 0 -> ExposureMode.PROGRAM_AUTO + 1 -> ExposureMode.APERTURE_PRIORITY + 2 -> ExposureMode.SHUTTER_PRIORITY + 3 -> ExposureMode.MANUAL + 4 -> ExposureMode.BULB + 5 -> ExposureMode.BULB_TIMER + 6 -> ExposureMode.TIME + 7 -> ExposureMode.SNAP_FOCUS_PROGRAM + else -> ExposureMode.UNKNOWN + } + + /** + * Decodes drive mode from DRIVE_MODE_CHARACTERISTIC (B29E6DE3) notification. + * + * Format: 1 byte enum (0-65 values; see dm-zharov Drive Mode list). + */ + fun decodeDriveMode(bytes: ByteArray): DriveMode { + if (bytes.isEmpty()) return DriveMode.UNKNOWN + val value = bytes[0].toInt() and 0xFF + + return when (value) { + 0 -> DriveMode.SINGLE_SHOOTING + 1 -> DriveMode.SELF_TIMER_10S + 2 -> DriveMode.SELF_TIMER_2S + 3, + in 18..32 -> DriveMode.CONTINUOUS_SHOOTING + 4, + 5, + 6, + 33, + 34, + in 46..50, + in 51..55 -> DriveMode.BRACKET + 7, + 8, + 9, + 35, + 36 -> DriveMode.MULTI_EXPOSURE + 10, + 11, + 12, + 13, + 14, + 15, + 37, + 38, + 39, + 40, + in 41..45, + in 61..65 -> DriveMode.INTERVAL + 16, + 17, + in 56..60 -> DriveMode.SINGLE_SHOOTING + else -> DriveMode.UNKNOWN + } + } + /** * Formats raw date/time bytes as a hex string for debugging. * @@ -154,17 +368,21 @@ object RicohProtocol : CameraProtocol { */ @OptIn(ExperimentalStdlibApi::class) fun formatDateTimeHex(bytes: ByteArray): String = buildString { - append(bytes.sliceArray(0..1).toHexString()) - append("_") - append(bytes[2].toHexString()) - append("_") - append(bytes[3].toHexString()) - append("_") - append(bytes[4].toHexString()) - append("_") - append(bytes[5].toHexString()) - append("_") - append(bytes[6].toHexString()) + if (bytes.size >= 7) { + append(bytes.sliceArray(0..1).toHexString()) + append("_") + append(bytes[2].toHexString()) + append("_") + append(bytes[3].toHexString()) + append("_") + append(bytes[4].toHexString()) + append("_") + append(bytes[5].toHexString()) + append("_") + append(bytes[6].toHexString()) + } else { + append(bytes.toHexString()) + } } /** @@ -174,25 +392,29 @@ object RicohProtocol : CameraProtocol { */ @OptIn(ExperimentalStdlibApi::class) fun formatLocationHex(bytes: ByteArray): String = buildString { - append(bytes.sliceArray(0..7).toHexString()) - append("_") - append(bytes.sliceArray(8..15).toHexString()) - append("_") - append(bytes.sliceArray(16..23).toHexString()) - append("_") - append(bytes.sliceArray(24..25).toHexString()) - append("_") - append(bytes[26].toHexString()) - append("_") - append(bytes[27].toHexString()) - append("_") - append(bytes[28].toHexString()) - append("_") - append(bytes[29].toHexString()) - append("_") - append(bytes[30].toHexString()) - append("_") - append(bytes[31].toHexString()) + if (bytes.size >= 32) { + append(bytes.sliceArray(0..7).toHexString()) + append("_") + append(bytes.sliceArray(8..15).toHexString()) + append("_") + append(bytes.sliceArray(16..23).toHexString()) + append("_") + append(bytes.sliceArray(24..25).toHexString()) + append("_") + append(bytes[26].toHexString()) + append("_") + append(bytes[27].toHexString()) + append("_") + append(bytes[28].toHexString()) + append("_") + append(bytes[29].toHexString()) + append("_") + append(bytes[30].toHexString()) + append("_") + append(bytes[31].toHexString()) + } else { + append(bytes.toHexString()) + } } } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegate.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegate.kt new file mode 100644 index 0000000..d53ac7b --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegate.kt @@ -0,0 +1,179 @@ +package dev.sebastiano.camerasync.vendors.ricoh + +import com.juul.kable.Peripheral +import com.juul.kable.WriteType +import com.juul.kable.characteristicOf +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.StorageInfo +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import kotlin.uuid.ExperimentalUuidApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +@OptIn(ExperimentalUuidApi::class) +class RicohRemoteControlDelegate( + private val peripheral: Peripheral, + @Suppress("UNUSED_PARAMETER") private val camera: Camera, +) : RemoteControlDelegate { + + private val _connectionMode = MutableStateFlow(ShootingConnectionMode.BLE_ONLY) + override val connectionMode: StateFlow = _connectionMode.asStateFlow() + + private val operationRequestCharacteristic + get() = + characteristicOf( + service = RicohGattSpec.Shooting.SERVICE_UUID, + characteristic = RicohGattSpec.Shooting.OPERATION_REQUEST_CHARACTERISTIC_UUID, + ) + + override suspend fun triggerCapture() { + // Start with AF (dm-zharov Operation Request: 1=Start, 1=AF) + peripheral.write( + operationRequestCharacteristic, + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_START, + RicohProtocol.OP_REQ_PARAM_AF, + ), + WriteType.WithResponse, + ) + delay(CAPTURE_DELAY_MS) + // Stop (2=Stop, 0=No AF) + peripheral.write( + operationRequestCharacteristic, + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_STOP, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + WriteType.WithResponse, + ) + } + + override suspend fun startBulbExposure() { + // Use NO_AF: bulb mode is for long exposures in dark conditions where the user has + // typically pre-focused manually. AF would hunt and fail, potentially blocking the start. + peripheral.write( + operationRequestCharacteristic, + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_START, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + WriteType.WithResponse, + ) + } + + override suspend fun stopBulbExposure() { + peripheral.write( + operationRequestCharacteristic, + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_STOP, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + WriteType.WithResponse, + ) + } + + override fun observeBatteryLevel(): Flow { + val characteristic = + characteristicOf( + service = RicohGattSpec.CameraState.SERVICE_UUID, + characteristic = RicohGattSpec.CameraState.BATTERY_LEVEL_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { RicohProtocol.decodeBatteryInfo(it) } + } + + override fun observeStorageStatus(): Flow { + val characteristic = + characteristicOf( + service = RicohGattSpec.CameraState.SERVICE_UUID, + characteristic = RicohGattSpec.CameraState.STORAGE_INFO_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { data -> + RicohProtocol.decodeStorageInfo(data) + } + } + + override fun observeCameraMode(): Flow { + val characteristic = + characteristicOf( + service = RicohGattSpec.Shooting.SERVICE_UUID, + characteristic = RicohGattSpec.Shooting.CAPTURE_MODE_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { data -> + // Capture mode byte: 0=Still, 2=Movie. + if (data.isEmpty()) return@map CameraMode.UNKNOWN + when (data[0].toInt() and 0xFF) { + 0 -> CameraMode.STILL_IMAGE + 2 -> CameraMode.MOVIE + else -> CameraMode.UNKNOWN + } + } + } + + override fun observeCaptureStatus(): Flow { + val characteristic = + characteristicOf( + service = RicohGattSpec.Shooting.SERVICE_UUID, + characteristic = RicohGattSpec.Shooting.CAPTURE_STATUS_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { RicohProtocol.decodeCaptureStatus(it) } + } + + override fun observeExposureMode(): Flow { + val characteristic = + characteristicOf( + service = RicohGattSpec.Shooting.SERVICE_UUID, + characteristic = RicohGattSpec.Shooting.SHOOTING_MODE_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { + RicohProtocol.decodeShootingMode(it).second + } + } + + override fun observeDriveMode(): Flow { + // Drive mode is on B29E6DE3 in the Shooting service. + val characteristic = + characteristicOf( + service = RicohGattSpec.Shooting.SERVICE_UUID, + characteristic = RicohGattSpec.Shooting.DRIVE_MODE_CHARACTERISTIC_UUID, + ) + return peripheral + .observe(characteristic) + .map { RicohProtocol.decodeDriveMode(it) } + .distinctUntilChanged() + } + + override suspend fun connectWifi() { + // Wi-Fi connection (WLAN-on write, read credentials, Android Wi-Fi connect) is not yet + // implemented. Do not set mode to FULL here: that would make Wi-Fi-dependent UI appear and + // Wi-Fi-only features silently fail. + throw UnsupportedOperationException("Wi-Fi connection is not yet implemented.") + } + + override suspend fun disconnectWifi() { + // Disconnect Wifi + _connectionMode.value = ShootingConnectionMode.BLE_ONLY + } + + companion object { + /** + * Delay between shutter press and release for normal captures. + * + * This ensures the camera has time to process the capture before releasing the shutter. For + * bulb mode, the shutter is held down until [stopBulbExposure] is called. + */ + private const val CAPTURE_DELAY_MS = 200L + + // No drive-mode echo filtering needed: we only observe the drive-mode characteristic. + } +} diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendor.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendor.kt index 29d9a87..11994b8 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendor.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendor.kt @@ -4,11 +4,25 @@ import android.bluetooth.le.ScanFilter import android.companion.BluetoothLeDeviceFilter import android.companion.DeviceFilter import android.os.ParcelUuid -import dev.sebastiano.camerasync.domain.vendor.CameraCapabilities +import com.juul.kable.Peripheral +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.vendor.AdvancedShootingCapabilities +import dev.sebastiano.camerasync.domain.vendor.AutofocusCapabilities +import dev.sebastiano.camerasync.domain.vendor.BatteryMonitoringCapabilities import dev.sebastiano.camerasync.domain.vendor.CameraGattSpec import dev.sebastiano.camerasync.domain.vendor.CameraProtocol import dev.sebastiano.camerasync.domain.vendor.CameraVendor +import dev.sebastiano.camerasync.domain.vendor.ConnectionModeSupport +import dev.sebastiano.camerasync.domain.vendor.ImageBrowsingCapabilities +import dev.sebastiano.camerasync.domain.vendor.ImageControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.LiveViewCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteCaptureCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.StorageMonitoringCapabilities +import dev.sebastiano.camerasync.domain.vendor.SyncCapabilities import dev.sebastiano.camerasync.domain.vendor.VendorConnectionDelegate +import dev.sebastiano.camerasync.domain.vendor.VideoRecordingCapabilities import java.util.regex.Pattern import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -77,6 +91,11 @@ object SonyCameraVendor : CameraVendor { override fun createConnectionDelegate(): VendorConnectionDelegate = SonyConnectionDelegate() + override fun createRemoteControlDelegate( + peripheral: Peripheral, + camera: Camera, + ): RemoteControlDelegate = SonyRemoteControlDelegate(peripheral, camera) + /** Checks if the manufacturer data indicates a Sony camera. */ private fun isSonyCamera(manufacturerData: Map): Boolean { val sonyData = manufacturerData[SONY_MANUFACTURER_ID] ?: return false @@ -103,14 +122,80 @@ object SonyCameraVendor : CameraVendor { /** Minimum protocol version that requires DD30/DD31 unlock sequence. */ const val PROTOCOL_VERSION_REQUIRES_UNLOCK = 65 - override fun getCapabilities(): CameraCapabilities { - return CameraCapabilities( - supportsFirmwareVersion = true, // Standard DIS - supportsDeviceName = false, // Setting device name is not standard for Sony via BLE + override fun getRemoteControlCapabilities(): RemoteControlCapabilities { + return RemoteControlCapabilities( + connectionModeSupport = + ConnectionModeSupport(bleOnlyShootingSupported = true, wifiAddsFeatures = true), + batteryMonitoring = + BatteryMonitoringCapabilities( + supported = true, + supportsMultiplePacks = true, + supportsPowerSourceDetection = true, + ), + storageMonitoring = + StorageMonitoringCapabilities( + supported = true, + supportsMultipleSlots = true, + supportsVideoCapacity = true, + ), + remoteCapture = + RemoteCaptureCapabilities( + supported = true, + requiresWifi = false, // Works via BLE FF01 + supportsHalfPressAF = true, + supportsTouchAF = true, // Wi-Fi only + supportsBulbMode = true, + supportsManualFocus = true, + supportsZoom = true, + supportsAELock = true, // Wi-Fi only + supportsFELock = true, // Wi-Fi only + supportsAWBLock = true, // Wi-Fi only + supportsCustomButtons = true, + ), + advancedShooting = + AdvancedShootingCapabilities( + supported = true, + requiresWifi = true, // PTP/IP properties + supportsExposureModeReading = true, + supportsDriveModeReading = true, + supportsSelfTimer = true, + supportsProgramShift = true, + supportsExposureCompensation = true, + ), + videoRecording = + VideoRecordingCapabilities( + supported = true, + requiresWifi = false, // BLE toggle + ), + liveView = + LiveViewCapabilities( + supported = true, + requiresWifi = true, + supportsPostView = true, + ), + autofocus = AutofocusCapabilities(supported = true, supportsFocusStatusReading = true), + imageControl = ImageControlCapabilities(supported = false), // Not supported + imageBrowsing = + ImageBrowsingCapabilities( + supported = true, + supportsThumbnails = true, + supportsPreview = true, + supportsFullDownload = true, + supportsExifReading = true, + supportsPushTransfer = true, + supportsDownloadResume = true, + ), + ) + } + + override fun getSyncCapabilities(): SyncCapabilities { + return SyncCapabilities( + supportsFirmwareVersion = true, + supportsDeviceName = false, supportsDateTimeSync = true, - supportsGeoTagging = false, // No separate toggle; location data includes time + supportsGeoTagging = false, supportsLocationSync = true, - requiresVendorPairing = true, // Sony requires writing to EE01 characteristic + requiresVendorPairing = true, supportsHardwareRevision = true, ) } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyGattSpec.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyGattSpec.kt index 1ab71cc..f4a6155 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyGattSpec.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyGattSpec.kt @@ -19,6 +19,13 @@ object SonyGattSpec : CameraGattSpec { /** Remote Control Service UUID - used for scanning and filtering Sony camera advertisements. */ val REMOTE_CONTROL_SERVICE_UUID: Uuid = Uuid.parse("8000FF00-FF00-FFFF-FFFF-FFFFFFFFFFFF") + /** Remote Control Command Characteristic (FF01) - write remote control codes. */ + val REMOTE_COMMAND_CHARACTERISTIC_UUID: Uuid = + Uuid.parse("0000FF01-0000-1000-8000-00805f9b34fb") + + /** Remote Control Status Characteristic (FF02) - notify for status updates. */ + val REMOTE_STATUS_CHARACTERISTIC_UUID: Uuid = Uuid.parse("0000FF02-0000-1000-8000-00805f9b34fb") + // ==================== Location Service (DD) ==================== /** Location Service UUID - used for GPS synchronization. */ val LOCATION_SERVICE_UUID: Uuid = Uuid.parse("8000DD00-DD00-FFFF-FFFF-FFFFFFFFFFFF") @@ -71,7 +78,17 @@ object SonyGattSpec : CameraGattSpec { /** Camera Control Service UUID - used for date/time, firmware version, and model name. */ val CAMERA_CONTROL_SERVICE_UUID: Uuid = Uuid.parse("8000CC00-CC00-FFFF-FFFF-FFFFFFFFFFFF") - /** Completion Status Characteristic (CC09) - read to check if time setting is done. */ + /** + * Camera Status / Time Completion Characteristic (CC09) - dual-purpose; Read, Notify. + * 1. Time setting completion: tag 0x0005 (1=Done, 0=Not Done). Used by date/time sync to check + * if time setting is done. + * 2. Camera status TLV: tag 0x0008 = Movie Recording state (1=Recording, 0=Not Recording). This + * is recording start/stop, not camera mode. Only value 1 implies MOVIE; value 0 cannot + * distinguish still vs movie idle. + * + * Remote control derives MOVIE mode only when 0x0008=1. Payloads that are only time-completion + * (e.g. after a sync) do not contain 0x0008 and must be ignored for mode observation. + */ val TIME_COMPLETION_STATUS_CHARACTERISTIC_UUID: Uuid = Uuid.parse("0000CC09-0000-1000-8000-00805f9b34fb") @@ -96,6 +113,12 @@ object SonyGattSpec : CameraGattSpec { val TIME_AREA_SETTING_CHARACTERISTIC_UUID: Uuid = Uuid.parse("0000CC13-0000-1000-8000-00805f9b34fb") + /** Battery Info Characteristic (CC10) - Notify/Read. */ + val BATTERY_INFO_CHARACTERISTIC_UUID: Uuid = Uuid.parse("0000CC10-0000-1000-8000-00805f9b34fb") + + /** Storage Info Characteristic (CC0F) - Notify/Read. */ + val STORAGE_INFO_CHARACTERISTIC_UUID: Uuid = Uuid.parse("0000CC0F-0000-1000-8000-00805f9b34fb") + // ==================== Overrides ==================== override val scanFilterServiceUuids: List = listOf(REMOTE_CONTROL_SERVICE_UUID, PAIRING_SERVICE_UUID) diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocol.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocol.kt index eb9723a..a7b41f4 100644 --- a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocol.kt +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocol.kt @@ -1,13 +1,20 @@ package dev.sebastiano.camerasync.vendors.sony +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.BatteryPosition +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.FocusStatus import dev.sebastiano.camerasync.domain.model.GpsLocation +import dev.sebastiano.camerasync.domain.model.PowerSource +import dev.sebastiano.camerasync.domain.model.RecordingStatus +import dev.sebastiano.camerasync.domain.model.ShutterStatus +import dev.sebastiano.camerasync.domain.model.StorageInfo import dev.sebastiano.camerasync.domain.vendor.CameraProtocol import java.nio.ByteBuffer import java.nio.ByteOrder import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import kotlin.math.abs /** * Protocol implementation for Sony Alpha cameras. @@ -257,4 +264,177 @@ object SonyProtocol : CameraProtocol { /** Creates the pairing initialization command. */ fun createPairingInit(): ByteArray = byteArrayOf(0x06, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00) + + // --- New Methods --- + + /** + * Bytes per battery pack in CC10 payload: Enable(1) + InfoLithium(1) + Position(1) + + * Status(1) + Percentage(4) = 8. Power supply status is a single byte after all pack(s). + */ + private const val BYTES_PER_BATTERY_PACK = 8 + + /** Decodes battery information from the CC10 characteristic. */ + fun decodeBatteryInfo(bytes: ByteArray): BatteryInfo { + // Per-battery-pack: Enable(1) + InfoLithium(1) + Position(1) + Status(1) + Percentage(4) = + // 8 bytes. Power supply status is a separate byte after the pack(s), so minimum 9 bytes + // to include power source; with 2 packs (e.g. grip) power is at index 16, not 8. + if (bytes.size < BYTES_PER_BATTERY_PACK) { + return BatteryInfo( + 0, + position = BatteryPosition.UNKNOWN, + powerSource = PowerSource.UNKNOWN, + ) + } + + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + + // First battery block: Offset 0–3 metadata, Offset 4–7 percentage (4 bytes BigEndian). + buffer.position(4) + val percentage = buffer.int.coerceIn(0, 100) + + val positionByte = bytes[2].toInt() and 0xFF + val position = + when (positionByte) { + 0x01 -> BatteryPosition.INTERNAL + 0x02 -> BatteryPosition.GRIP_1 + 0x03 -> BatteryPosition.GRIP_2 + else -> BatteryPosition.UNKNOWN + } + + // Power source is the byte after all 8-byte pack(s); only present when payload length is + // 8*k+1 (e.g. 9 or 17). When absent (e.g. exactly 8 or 16 bytes), do not read pack data as + // power. + val hasPowerByte = bytes.size >= 9 && (bytes.size - 1) % BYTES_PER_BATTERY_PACK == 0 + val powerSource: PowerSource + if (hasPowerByte) { + val powerSourceIndex = bytes.size - 1 + val powerSourceByte = bytes[powerSourceIndex].toInt() and 0xFF + powerSource = + when (powerSourceByte) { + 0x03 -> PowerSource.USB + else -> PowerSource.BATTERY + } + } else { + powerSource = PowerSource.UNKNOWN + } + + return BatteryInfo( + levelPercentage = percentage, + isCharging = powerSource == PowerSource.USB, + powerSource = powerSource, + position = position, + ) + } + + /** Decodes storage info from CC0F. */ + fun decodeStorageInfo(bytes: ByteArray): StorageInfo { + // "Per-slot (Slot 1, Slot 2): Status, Remaining shots (4-byte), Remaining time (4-byte)" + // Status: 0=No Media, 1=Media Present, 2=Format Required? (Guessing based on description) + // Let's check docs again carefully. + // "Status: No Media / Media Present / Format Required" -> likely 1 byte enum. + // Remaining shots: 4 bytes. + // Remaining time: 4 bytes. + // Total per slot: 1+4+4 = 9 bytes. + + if (bytes.size < 9) return StorageInfo(isPresent = false) + + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + + val status = buffer.get().toInt() and 0xFF + val shots = buffer.int + // val time = buffer.int // Remaining video seconds + + // Status mapping (heuristic) + // 0x00: No Media + // 0x01: Ready + // 0x02: Format required / Error? + val isPresent = status > 0 + val isFull = shots == 0 // heuristic + + return StorageInfo(slot = 1, isPresent = isPresent, remainingShots = shots, isFull = isFull) + } + + /** + * Decodes camera status from CC09 (TLV). + * + * Tag 0x0008 = Movie Recording state (1=Recording, 0=Not Recording). This is recording + * start/stop, not camera mode. Only value 1 (recording) implies MOVIE mode; value 0 (not + * recording) cannot distinguish still mode from movie mode idle, so UNKNOWN is returned to + * avoid conflating recording state with mode. + * + * Other tags (e.g. 0x0005 time-setting completion) are ignored. Returns UNKNOWN when no 0x0008 + * tag is present. Callers should filter out UNKNOWN so time-completion-only notifications do + * not cause spurious UI updates. + */ + fun decodeCameraStatus(bytes: ByteArray): CameraMode { + var pos = 0 + while (pos + 4 <= bytes.size) { + val tag = ((bytes[pos].toInt() and 0xFF) shl 8) or (bytes[pos + 1].toInt() and 0xFF) + val length = + ((bytes[pos + 2].toInt() and 0xFF) shl 8) or (bytes[pos + 3].toInt() and 0xFF) + pos += 4 + if (pos + length > bytes.size) break + if (tag == 0x0008 && length >= 1) { + val value = bytes[pos].toInt() and 0xFF + return if (value == 1) CameraMode.MOVIE else CameraMode.UNKNOWN + } + pos += length + } + return CameraMode.UNKNOWN + } + + /** + * FF02 notification format: [0x02, typeByte, valueByte]. Returns null if payload is not a + * recognized FF02 status. + */ + fun parseFf02Notification(bytes: ByteArray): Ff02Notification? { + if (bytes.size < 3 || bytes[0].toInt() != 0x02) return null + val type = bytes[1].toInt() and 0xFF + val value = bytes[2].toInt() and 0xFF + return when (type) { + 0x3F -> + Ff02Notification.Focus(if (value == 0x20) FocusStatus.LOCKED else FocusStatus.LOST) + 0xA0 -> + Ff02Notification.Shutter( + if (value == 0x20) ShutterStatus.ACTIVE else ShutterStatus.READY + ) + 0xD5 -> + Ff02Notification.Recording( + if (value == 0x20) RecordingStatus.RECORDING else RecordingStatus.IDLE + ) + else -> null + } + } + + /** Parsed FF02 (RemoteNotify) notification payload. */ + sealed class Ff02Notification { + data class Focus(val status: FocusStatus) : Ff02Notification() + + data class Shutter(val status: ShutterStatus) : Ff02Notification() + + data class Recording(val status: RecordingStatus) : Ff02Notification() + } + + /** + * Encodes a remote control command for FF01. + * + * Format: [0x01, code] or [0x02, code, parameter] + */ + fun encodeRemoteControlCommand(code: Int, parameter: Int? = null): ByteArray = + if (parameter != null) { + byteArrayOf(0x02, code.toByte(), parameter.toByte()) + } else { + byteArrayOf(0x01, code.toByte()) + } + + // Remote Control Codes (FF01) + const val RC_SHUTTER_HALF_PRESS = 0x07 + const val RC_SHUTTER_HALF_RELEASE = 0x06 + const val RC_SHUTTER_FULL_PRESS = 0x09 + const val RC_SHUTTER_FULL_RELEASE = 0x08 + const val RC_VIDEO_REC = 0x0E + const val RC_FOCUS_NEAR = 0x47 + const val RC_FOCUS_FAR = 0x45 + const val RC_ZOOM_TELE = 0x6D + const val RC_ZOOM_WIDE = 0x6B } diff --git a/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegate.kt b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegate.kt new file mode 100644 index 0000000..4d3266f --- /dev/null +++ b/app/src/main/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegate.kt @@ -0,0 +1,492 @@ +package dev.sebastiano.camerasync.vendors.sony + +import com.juul.kable.Characteristic +import com.juul.kable.Peripheral +import com.juul.kable.WriteType +import com.juul.kable.characteristicOf +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.CustomButton +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.FocusStatus +import dev.sebastiano.camerasync.domain.model.LiveViewFrame +import dev.sebastiano.camerasync.domain.model.RecordingStatus +import dev.sebastiano.camerasync.domain.model.ShutterStatus +import dev.sebastiano.camerasync.domain.model.StorageInfo +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import kotlin.uuid.ExperimentalUuidApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +@OptIn(ExperimentalUuidApi::class) +class SonyRemoteControlDelegate( + private val peripheral: Peripheral, + @Suppress("UNUSED_PARAMETER") private val camera: Camera, + /** + * Optional dispatcher for the BLE trigger sequence (withTimeoutOrNull). When provided (e.g. + * test dispatcher), timeouts use that dispatcher's scheduler for deterministic tests. + */ + private val captureDispatcher: CoroutineDispatcher? = null, +) : RemoteControlDelegate { + + private val _connectionMode = MutableStateFlow(ShootingConnectionMode.BLE_ONLY) + override val connectionMode: StateFlow = _connectionMode.asStateFlow() + + override suspend fun triggerCapture() { + if (_connectionMode.value == ShootingConnectionMode.FULL) { + throw UnsupportedOperationException( + "Remote capture in Wi-Fi mode is not yet implemented. Use BLE mode for shutter control." + ) + } else { + // BLE: Event-driven sequence per BLE_STATE_MONITORING.md §2.5 + // 1. Half Down → wait for FF02 focus acquired (or timeout) → 2. Full Down → + // wait for FF02 shutter active → 3. Full Up → 4. Half Up + val commandChar = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + val notifyChar = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_STATUS_CHARACTERISTIC_UUID, + ) + + val dispatcher = captureDispatcher ?: Dispatchers.Default + withContext(dispatcher) { + coroutineScope { runTriggerSequence(commandChar, notifyChar, this) } + } + } + } + + private suspend fun runTriggerSequence( + commandChar: Characteristic, + notifyChar: Characteristic, + shareScope: CoroutineScope, + ) { + // Use a dedicated job so we can cancel the SharedFlow collector when the sequence ends. + // Otherwise coroutineScope would wait forever for the shareIn child (with Eagerly or + // until WhileSubscribed "stop" runs). + val shareJob = Job() + val ff02Scope = CoroutineScope(shareScope.coroutineContext + shareJob) + val ff02Parsed = + peripheral + .observe(notifyChar) + .mapNotNull { SonyProtocol.parseFf02Notification(it) } + .shareIn( + ff02Scope, + // Use WhileSubscribed(stopTimeoutMillis = 6_000) to keep the upstream alive + // across the steps of the + // sequence (which may have gaps between subscribers) but still allow the + // sharing coroutine to + // complete when the last subscriber (the final step) is done. + // + // Using Eagerly would cause `shareIn` to launch a coroutine that never + // completes, making the + // parent `coroutineScope` wait forever and hang `triggerCapture`. + // + // The 6s timeout covers the longest wait (SHUTTER_ACTIVE_TIMEOUT_MS = 5000ms) + // plus a safety buffer. + // Cleanup is guaranteed by `shareJob.cancel()` in `finally` regardless of + // timeout. + SharingStarted.WhileSubscribed(stopTimeoutMillis = 6_000), + replay = 1, + ) + try { + runTriggerSequenceSteps(commandChar, ff02Parsed) + } finally { + shareJob.cancel() + } + } + + private suspend fun runTriggerSequenceSteps( + commandChar: Characteristic, + ff02Parsed: SharedFlow, + ) { + var completedNormally = false + try { + // 1. Shutter Half Down (acquire focus) + peripheral.write( + commandChar, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_PRESS), + WriteType.WithoutResponse, + ) + // 2. Wait for focus acquired (3s timeout; proceed anyway if MF or no response) + withTimeoutOrNull(FOCUS_ACQUIRED_TIMEOUT_MS) { + ff02Parsed + .filter { + it is SonyProtocol.Ff02Notification.Focus && it.status == FocusStatus.LOCKED + } + .first() + } + // 3. Shutter Full Down (take picture) + peripheral.write( + commandChar, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_PRESS), + WriteType.WithoutResponse, + ) + // 4. Wait for shutter active (picture taken) + withTimeoutOrNull(SHUTTER_ACTIVE_TIMEOUT_MS) { + ff02Parsed + .filter { + it is SonyProtocol.Ff02Notification.Shutter && + it.status == ShutterStatus.ACTIVE + } + .first() + } + // 5. Full Up, 6. Half Up (matched pairs per doc) + peripheral.write( + commandChar, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_RELEASE), + WriteType.WithoutResponse, + ) + peripheral.write( + commandChar, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_RELEASE), + WriteType.WithoutResponse, + ) + completedNormally = true + } finally { + if (!completedNormally) { + // Release shutter to avoid stuck AF/capture if a write or wait failed mid-sequence. + // Sending both is safe regardless of where we failed (full press, half press, or + // neither). + try { + peripheral.write( + commandChar, + SonyProtocol.encodeRemoteControlCommand( + SonyProtocol.RC_SHUTTER_FULL_RELEASE + ), + WriteType.WithoutResponse, + ) + peripheral.write( + commandChar, + SonyProtocol.encodeRemoteControlCommand( + SonyProtocol.RC_SHUTTER_HALF_RELEASE + ), + WriteType.WithoutResponse, + ) + } catch (_: Throwable) { + // Best-effort cleanup; don't mask original failure + } + } + } + } + + companion object { + /** + * Timeout waiting for FF02 focus-acquired; proceed with shutter anyway if exceeded (e.g. + * MF). + */ + private const val FOCUS_ACQUIRED_TIMEOUT_MS = 3_000L + /** Timeout waiting for FF02 shutter-active after full press. */ + private const val SHUTTER_ACTIVE_TIMEOUT_MS = 5_000L + } + + override suspend fun startBulbExposure() { + if (_connectionMode.value != ShootingConnectionMode.BLE_ONLY) { + throw UnsupportedOperationException( + "Bulb exposure in Wi-Fi mode is not yet implemented." + ) + } + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + // BLE: Hold shutter full down (exposure runs until release) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_PRESS), + WriteType.WithoutResponse, + ) + } + + override suspend fun stopBulbExposure() { + if (_connectionMode.value != ShootingConnectionMode.BLE_ONLY) { + throw UnsupportedOperationException( + "Bulb exposure in Wi-Fi mode is not yet implemented." + ) + } + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_RELEASE), + WriteType.WithoutResponse, + ) + } + + override fun observeBatteryLevel(): Flow { + val characteristic = + characteristicOf( + service = SonyGattSpec.CAMERA_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.BATTERY_INFO_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { SonyProtocol.decodeBatteryInfo(it) } + } + + override fun observeStorageStatus(): Flow { + val characteristic = + characteristicOf( + service = SonyGattSpec.CAMERA_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.STORAGE_INFO_CHARACTERISTIC_UUID, + ) + return peripheral.observe(characteristic).map { SonyProtocol.decodeStorageInfo(it) } + } + + override fun observeCameraMode(): Flow { + val characteristic = + characteristicOf( + service = SonyGattSpec.CAMERA_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.TIME_COMPLETION_STATUS_CHARACTERISTIC_UUID, // CC09 + ) + return peripheral + .observe(characteristic) + .map { SonyProtocol.decodeCameraStatus(it) } + .filter { it != CameraMode.UNKNOWN } + } + + override fun observeCaptureStatus(): Flow { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_STATUS_CHARACTERISTIC_UUID, + ) + return peripheral + .observe(characteristic) + .mapNotNull { SonyProtocol.parseFf02Notification(it) } + .mapNotNull { notif -> + when (notif) { + is SonyProtocol.Ff02Notification.Shutter -> + when (notif.status) { + ShutterStatus.READY -> CaptureStatus.Idle + ShutterStatus.ACTIVE -> CaptureStatus.Capturing + else -> null + } + else -> null + } + } + } + + override fun observeExposureMode(): Flow = + // Exposure mode is not available over BLE. When Wi-Fi/PTP is added, derive from + // connectionModeFlow (e.g. flatMapLatest) so the flow adapts when mode changes to FULL. + emptyFlow() + + override fun observeDriveMode(): Flow = + // Drive mode is not available over BLE. When Wi-Fi/PTP is added, derive from + // connectionModeFlow (e.g. flatMapLatest) so the flow adapts when mode changes to FULL. + emptyFlow() + + override suspend fun connectWifi() { + // Wi-Fi connection (CC08, credentials, Android Wifi, PTP/IP) is not yet implemented. + // Do not set mode to FULL here: that would make triggerCapture/startBulbExposure/ + // stopBulbExposure no-ops with no user feedback. + throw UnsupportedOperationException("Wi-Fi connection is not yet implemented.") + } + + override suspend fun disconnectWifi() { + // Teardown PTP/IP + // Disconnect Wifi + _connectionMode.value = ShootingConnectionMode.BLE_ONLY + } + + // --- Sony Specific Features --- + + override suspend fun halfPressAF() { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_PRESS), + WriteType.WithoutResponse, + ) + } + + override suspend fun releaseAF() { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_RELEASE), + WriteType.WithoutResponse, + ) + } + + override suspend fun focusNear(speed: Int) { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_FOCUS_NEAR, speed), + WriteType.WithoutResponse, + ) + } + + override suspend fun focusFar(speed: Int) { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_FOCUS_FAR, speed), + WriteType.WithoutResponse, + ) + } + + override suspend fun zoomIn(speed: Int) { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_ZOOM_TELE, speed), + WriteType.WithoutResponse, + ) + } + + override suspend fun zoomOut(speed: Int) { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_ZOOM_WIDE, speed), + WriteType.WithoutResponse, + ) + } + + override suspend fun pressCustomButton(button: CustomButton) { + // BLE: Write FF01 custom code + // Need custom button codes (AF-ON, C1) + } + + override suspend fun releaseCustomButton(button: CustomButton) { + // BLE: Write FF01 release code + } + + override fun observeFocusStatus(): Flow { + // Sony always supports focus status over BLE via FF02 notifications + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_STATUS_CHARACTERISTIC_UUID, + ) + return peripheral + .observe(characteristic) + .mapNotNull { SonyProtocol.parseFf02Notification(it) } + .mapNotNull { notif -> (notif as? SonyProtocol.Ff02Notification.Focus)?.status } + } + + override fun observeShutterStatus(): Flow { + // Sony always supports shutter status over BLE via FF02 notifications + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_STATUS_CHARACTERISTIC_UUID, + ) + return peripheral + .observe(characteristic) + .mapNotNull { SonyProtocol.parseFf02Notification(it) } + .mapNotNull { notif -> (notif as? SonyProtocol.Ff02Notification.Shutter)?.status } + } + + override suspend fun touchAF(x: Float, y: Float) { + check(_connectionMode.value == ShootingConnectionMode.FULL) { + "Touch AF requires a Wi-Fi connection." + } + throw UnsupportedOperationException("Touch AF over Wi-Fi is not yet implemented.") + } + + override suspend fun toggleAELock() { + check(_connectionMode.value == ShootingConnectionMode.FULL) { + "AE Lock requires a Wi-Fi connection." + } + throw UnsupportedOperationException("AE Lock over Wi-Fi is not yet implemented.") + } + + override suspend fun toggleFELock() { + check(_connectionMode.value == ShootingConnectionMode.FULL) { + "FE Lock requires a Wi-Fi connection." + } + throw UnsupportedOperationException("FE Lock over Wi-Fi is not yet implemented.") + } + + override suspend fun toggleAWBLock() { + check(_connectionMode.value == ShootingConnectionMode.FULL) { + "AWB Lock requires a Wi-Fi connection." + } + throw UnsupportedOperationException("AWB Lock over Wi-Fi is not yet implemented.") + } + + override fun observeLiveView(): Flow? { + if (_connectionMode.value != ShootingConnectionMode.FULL) return null + return emptyFlow() // PTP/IP LiveView stream + } + + override suspend fun toggleVideoRecording() { + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID, + ) + peripheral.write( + characteristic, + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_VIDEO_REC), + WriteType.WithoutResponse, + ) + } + + override fun observeRecordingStatus(): Flow { + // Sony always supports recording status over BLE via FF02 notifications + val characteristic = + characteristicOf( + service = SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID, + characteristic = SonyGattSpec.REMOTE_STATUS_CHARACTERISTIC_UUID, + ) + return peripheral + .observe(characteristic) + .mapNotNull { SonyProtocol.parseFf02Notification(it) } + .mapNotNull { notif -> (notif as? SonyProtocol.Ff02Notification.Recording)?.status } + } +} diff --git a/app/src/main/res/drawable/battery_android_1_24dp.xml b/app/src/main/res/drawable/battery_android_1_24dp.xml new file mode 100644 index 0000000..d0f39cc --- /dev/null +++ b/app/src/main/res/drawable/battery_android_1_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_2_24dp.xml b/app/src/main/res/drawable/battery_android_2_24dp.xml new file mode 100644 index 0000000..b2b0e2c --- /dev/null +++ b/app/src/main/res/drawable/battery_android_2_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_3_24dp.xml b/app/src/main/res/drawable/battery_android_3_24dp.xml new file mode 100644 index 0000000..05ec6c2 --- /dev/null +++ b/app/src/main/res/drawable/battery_android_3_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_4_24dp.xml b/app/src/main/res/drawable/battery_android_4_24dp.xml new file mode 100644 index 0000000..14cb8ff --- /dev/null +++ b/app/src/main/res/drawable/battery_android_4_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_5_24dp.xml b/app/src/main/res/drawable/battery_android_5_24dp.xml new file mode 100644 index 0000000..ea3c5c4 --- /dev/null +++ b/app/src/main/res/drawable/battery_android_5_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_6_24dp.xml b/app/src/main/res/drawable/battery_android_6_24dp.xml new file mode 100644 index 0000000..4d799c6 --- /dev/null +++ b/app/src/main/res/drawable/battery_android_6_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_alert_24dp.xml b/app/src/main/res/drawable/battery_android_alert_24dp.xml new file mode 100644 index 0000000..e63e5f7 --- /dev/null +++ b/app/src/main/res/drawable/battery_android_alert_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_bolt_24dp.xml b/app/src/main/res/drawable/battery_android_bolt_24dp.xml new file mode 100644 index 0000000..873039d --- /dev/null +++ b/app/src/main/res/drawable/battery_android_bolt_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_full_24dp.xml b/app/src/main/res/drawable/battery_android_full_24dp.xml new file mode 100644 index 0000000..7dac09a --- /dev/null +++ b/app/src/main/res/drawable/battery_android_full_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/battery_android_question_24dp.xml b/app/src/main/res/drawable/battery_android_question_24dp.xml new file mode 100644 index 0000000..f79d012 --- /dev/null +++ b/app/src/main/res/drawable/battery_android_question_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/bulb_24dp.xml b/app/src/main/res/drawable/bulb_24dp.xml new file mode 100644 index 0000000..17944fa --- /dev/null +++ b/app/src/main/res/drawable/bulb_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/drive_bracket.xml b/app/src/main/res/drawable/drive_bracket.xml new file mode 100644 index 0000000..fce87d3 --- /dev/null +++ b/app/src/main/res/drawable/drive_bracket.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/drive_continuous.xml b/app/src/main/res/drawable/drive_continuous.xml new file mode 100644 index 0000000..f156224 --- /dev/null +++ b/app/src/main/res/drawable/drive_continuous.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/drive_single.xml b/app/src/main/res/drawable/drive_single.xml new file mode 100644 index 0000000..e2814b0 --- /dev/null +++ b/app/src/main/res/drawable/drive_single.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/drive_timer_10s.xml b/app/src/main/res/drawable/drive_timer_10s.xml new file mode 100644 index 0000000..99c533e --- /dev/null +++ b/app/src/main/res/drawable/drive_timer_10s.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/app/src/main/res/drawable/drive_timer_2s.xml b/app/src/main/res/drawable/drive_timer_2s.xml new file mode 100644 index 0000000..53d9150 --- /dev/null +++ b/app/src/main/res/drawable/drive_timer_2s.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/app/src/main/res/drawable/focus_auto_auto.xml b/app/src/main/res/drawable/focus_auto_auto.xml new file mode 100644 index 0000000..a8261ec --- /dev/null +++ b/app/src/main/res/drawable/focus_auto_auto.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/focus_auto_continuous.xml b/app/src/main/res/drawable/focus_auto_continuous.xml new file mode 100644 index 0000000..3337cb0 --- /dev/null +++ b/app/src/main/res/drawable/focus_auto_continuous.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/focus_auto_single.xml b/app/src/main/res/drawable/focus_auto_single.xml new file mode 100644 index 0000000..6158a47 --- /dev/null +++ b/app/src/main/res/drawable/focus_auto_single.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/focus_manual.xml b/app/src/main/res/drawable/focus_manual.xml new file mode 100644 index 0000000..e48634c --- /dev/null +++ b/app/src/main/res/drawable/focus_manual.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/mode_aperture.xml b/app/src/main/res/drawable/mode_aperture.xml new file mode 100644 index 0000000..5a3f2ed --- /dev/null +++ b/app/src/main/res/drawable/mode_aperture.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/mode_auto.xml b/app/src/main/res/drawable/mode_auto.xml new file mode 100644 index 0000000..bd42ac3 --- /dev/null +++ b/app/src/main/res/drawable/mode_auto.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/mode_manual.xml b/app/src/main/res/drawable/mode_manual.xml new file mode 100644 index 0000000..7dca08a --- /dev/null +++ b/app/src/main/res/drawable/mode_manual.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/mode_program_auto.xml b/app/src/main/res/drawable/mode_program_auto.xml new file mode 100644 index 0000000..fbdcbc9 --- /dev/null +++ b/app/src/main/res/drawable/mode_program_auto.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/mode_shutter.xml b/app/src/main/res/drawable/mode_shutter.xml new file mode 100644 index 0000000..9d79017 --- /dev/null +++ b/app/src/main/res/drawable/mode_shutter.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/sd_card_24dp.xml b/app/src/main/res/drawable/sd_card_24dp.xml new file mode 100644 index 0000000..61c9303 --- /dev/null +++ b/app/src/main/res/drawable/sd_card_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7adee5..6d7b5b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ OK Cancel Retry + Reset remote shooting Camera Sync @@ -153,6 +154,32 @@ Notifications Show sync status updates + + Remote control + Connected via Bluetooth (Basic mode) + Connected via Wi‑Fi (Full control) + Full mode (Wi‑Fi) coming soon + Disconnect Wi‑Fi (save battery) + Battery: %1$d%% + Battery: -- + Storage: %1$s shots + Storage: No card + Shutter + Take photo + Live view stream + Touch AF supported + Shooting settings + Mode + Drive + Focus + Image control + Standard + Half-press AF supported + Performing action... + Go back + Device not connected via BLE. Connect from the main list first. + Remote control not supported for this device + 1 camera syncing… diff --git a/app/src/test/AndroidManifest.xml b/app/src/test/AndroidManifest.xml new file mode 100644 index 0000000..15c81bd --- /dev/null +++ b/app/src/test/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraConnectionTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraConnectionTest.kt new file mode 100644 index 0000000..e91f9e1 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/data/repository/KableCameraConnectionTest.kt @@ -0,0 +1,90 @@ +package dev.sebastiano.camerasync.data.repository + +import com.juul.kable.Peripheral +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.vendor.CameraVendor +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.SyncCapabilities +import dev.sebastiano.camerasync.domain.vendor.VendorConnectionDelegate +import dev.sebastiano.camerasync.fakes.FakeRemoteControlDelegate +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Tests for [KableCameraConnection], focusing on + * [`getRemoteControlDelegate()`][KableCameraConnection.getRemoteControlDelegate] caching and retry + * behavior. + */ +class KableCameraConnectionTest { + + private fun createConnection(vendor: CameraVendor): KableCameraConnection { + val peripheral = + mockk(relaxed = true) { + every { state } returns MutableStateFlow(mockk(relaxed = true)) + every { services } returns MutableStateFlow(null) + } + val connectionDelegate = mockk(relaxed = true) + val camera = + Camera( + identifier = "test-id", + name = "Test Camera", + macAddress = "AA:BB:CC:DD:EE:FF", + vendor = vendor, + ) + return KableCameraConnection(camera, peripheral, connectionDelegate) + } + + @Test + fun `getRemoteControlDelegate returns delegate from vendor and caches it`() { + val delegate = FakeRemoteControlDelegate() + val vendor = + mockk(relaxed = true) { + every { getRemoteControlCapabilities() } returns RemoteControlCapabilities() + every { getSyncCapabilities() } returns SyncCapabilities() + every { createRemoteControlDelegate(any(), any()) } returns delegate + } + val connection = createConnection(vendor) + + val first = connection.getRemoteControlDelegate() + val second = connection.getRemoteControlDelegate() + + assertNotNull(first) + assertSame(delegate, first) + assertSame(first, second) + verify(exactly = 1) { vendor.createRemoteControlDelegate(any(), any()) } + } + + @Test + fun `getRemoteControlDelegate retries after failure and succeeds`() { + val delegate = FakeRemoteControlDelegate() + var createCallCount = 0 + val vendor = + mockk(relaxed = true) { + every { getRemoteControlCapabilities() } returns RemoteControlCapabilities() + every { getSyncCapabilities() } returns SyncCapabilities() + every { createRemoteControlDelegate(any(), any()) } answers + { + createCallCount++ + if (createCallCount == 1) { + error("creation failed") + } + delegate + } + } + val connection = createConnection(vendor) + + val first = connection.getRemoteControlDelegate() + val second = connection.getRemoteControlDelegate() + + assertNull(first) + assertNotNull(second) + assertSame(delegate, second) + verify(exactly = 2) { vendor.createRemoteControlDelegate(any(), any()) } + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DeviceCardTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DeviceCardTest.kt new file mode 100644 index 0000000..28bed20 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DeviceCardTest.kt @@ -0,0 +1,242 @@ +package dev.sebastiano.camerasync.devices + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dev.sebastiano.camerasync.domain.model.DeviceConnectionState +import dev.sebastiano.camerasync.domain.model.PairedDevice +import dev.sebastiano.camerasync.domain.model.PairedDeviceWithState +import dev.sebastiano.camerasync.ui.theme.CameraSyncTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * UI tests for [DeviceCard], focusing on Remote Control button visibility based on connection + * state. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class DeviceCardTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun remoteControlButton_visibleWhenConnected() { + composeTestRule.setContent { + CameraSyncTheme { + DeviceCard( + deviceWithState = + PairedDeviceWithState( + device = + PairedDevice( + macAddress = "00:11:22:33:44:55", + name = "GR IIIx", + vendorId = "ricoh", + isEnabled = true, + ), + connectionState = DeviceConnectionState.Connected(), + ), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = true, + showPairingName = false, + ), + onEnabledChange = {}, + onUnpairClick = {}, + onRetryClick = {}, + onRemoteControlClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Ricoh GR IIIx").performClick() + composeTestRule.onNodeWithText("Remote control").assertExists() + } + + @Test + fun remoteControlButton_hiddenWhenConnectedButUnsupported() { + composeTestRule.setContent { + CameraSyncTheme { + DeviceCard( + deviceWithState = + PairedDeviceWithState( + device = + PairedDevice( + macAddress = "00:11:22:33:44:55", + name = "GR IIIx", + vendorId = "ricoh", + isEnabled = true, + ), + connectionState = DeviceConnectionState.Connected(), + ), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = false, + showPairingName = false, + ), + onEnabledChange = {}, + onUnpairClick = {}, + onRetryClick = {}, + onRemoteControlClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Ricoh GR IIIx").performClick() + composeTestRule.onNodeWithText("Remote control").assertDoesNotExist() + } + + @Test + fun remoteControlButton_visibleWhenSyncing() { + composeTestRule.setContent { + CameraSyncTheme { + DeviceCard( + deviceWithState = + PairedDeviceWithState( + device = + PairedDevice( + macAddress = "00:11:22:33:44:55", + name = "GR IIIx", + vendorId = "ricoh", + isEnabled = true, + ), + connectionState = + DeviceConnectionState.Syncing(firmwareVersion = "1.10"), + ), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = true, + showPairingName = false, + ), + onEnabledChange = {}, + onUnpairClick = {}, + onRetryClick = {}, + onRemoteControlClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Ricoh GR IIIx").performClick() + composeTestRule.onNodeWithText("Remote control").assertExists() + } + + @Test + fun remoteControlButton_hiddenWhenDisconnected() { + composeTestRule.setContent { + CameraSyncTheme { + DeviceCard( + deviceWithState = + PairedDeviceWithState( + device = + PairedDevice( + macAddress = "00:11:22:33:44:55", + name = "GR IIIx", + vendorId = "ricoh", + isEnabled = true, + ), + connectionState = DeviceConnectionState.Disconnected, + ), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + "My Camera", + supportsRemoteControl = true, + showPairingName = true, + ), + onEnabledChange = {}, + onUnpairClick = {}, + onRetryClick = {}, + onRemoteControlClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Ricoh GR IIIx (My Camera)").performClick() + composeTestRule.onNodeWithText("Remote control").assertDoesNotExist() + } + + @Test + fun remoteControlButton_hiddenWhenUnreachable() { + composeTestRule.setContent { + CameraSyncTheme { + DeviceCard( + deviceWithState = + PairedDeviceWithState( + device = + PairedDevice( + macAddress = "00:11:22:33:44:55", + name = "Alpha 7 IV", + vendorId = "sony", + isEnabled = true, + ), + connectionState = DeviceConnectionState.Unreachable, + ), + displayInfo = + DeviceDisplayInfo( + "Sony", + "Alpha 7 IV", + "Studio A", + supportsRemoteControl = true, + showPairingName = true, + ), + onEnabledChange = {}, + onUnpairClick = {}, + onRetryClick = {}, + onRemoteControlClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Sony Alpha 7 IV (Studio A)").performClick() + composeTestRule.onNodeWithText("Remote control").assertDoesNotExist() + } + + @Test + fun remoteControlButton_hiddenWhenDisabled() { + composeTestRule.setContent { + CameraSyncTheme { + DeviceCard( + deviceWithState = + PairedDeviceWithState( + device = + PairedDevice( + macAddress = "00:11:22:33:44:55", + name = "GR IIIx", + vendorId = "ricoh", + isEnabled = false, + ), + connectionState = DeviceConnectionState.Disabled, + ), + displayInfo = + DeviceDisplayInfo( + "Ricoh", + "GR IIIx", + null, + supportsRemoteControl = true, + showPairingName = false, + ), + onEnabledChange = {}, + onUnpairClick = {}, + onRetryClick = {}, + onRemoteControlClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Ricoh GR IIIx").performClick() + composeTestRule.onNodeWithText("Remote control").assertDoesNotExist() + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModelTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModelTest.kt index c987338..ab4bfa3 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModelTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/devices/DevicesListViewModelTest.kt @@ -5,6 +5,7 @@ import dev.sebastiano.camerasync.CameraSyncApp import dev.sebastiano.camerasync.domain.model.PairedDevice import dev.sebastiano.camerasync.fakes.FakeBatteryOptimizationChecker import dev.sebastiano.camerasync.fakes.FakeBluetoothBondingChecker +import dev.sebastiano.camerasync.fakes.FakeIntentFactory import dev.sebastiano.camerasync.fakes.FakeIssueReporter import dev.sebastiano.camerasync.fakes.FakeKhronicleLogger import dev.sebastiano.camerasync.fakes.FakeLocationRepository @@ -38,7 +39,7 @@ class DevicesListViewModelTest { private lateinit var bluetoothBondingChecker: FakeBluetoothBondingChecker private lateinit var batteryOptimizationChecker: FakeBatteryOptimizationChecker private lateinit var issueReporter: FakeIssueReporter - private lateinit var intentFactory: dev.sebastiano.camerasync.fakes.FakeIntentFactory + private lateinit var intentFactory: FakeIntentFactory private lateinit var viewModel: DevicesListViewModel private val testDispatcher = UnconfinedTestDispatcher() @@ -56,7 +57,7 @@ class DevicesListViewModelTest { bluetoothBondingChecker = FakeBluetoothBondingChecker() batteryOptimizationChecker = FakeBatteryOptimizationChecker() issueReporter = FakeIssueReporter() - intentFactory = dev.sebastiano.camerasync.fakes.FakeIntentFactory() + intentFactory = FakeIntentFactory() viewModel = DevicesListViewModel( diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManagerTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManagerTest.kt new file mode 100644 index 0000000..f4ae919 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/DeviceConnectionManagerTest.kt @@ -0,0 +1,56 @@ +package dev.sebastiano.camerasync.devicesync + +import dev.sebastiano.camerasync.domain.repository.CameraConnection +import io.mockk.mockk +import kotlinx.coroutines.Job +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DeviceConnectionManagerTest { + + @Test + fun `removeConnectionIfMatches keeps newer connection`() { + val manager = DeviceConnectionManager() + val macAddress = "AA:BB:CC:DD:EE:FF" + val initialConnection = mockk(relaxed = true) + val replacementConnection = mockk(relaxed = true) + val initialJob = Job() + val replacementJob = Job() + + try { + manager.addConnection(macAddress, initialConnection, initialJob) + manager.addConnection(macAddress, replacementConnection, replacementJob) + + val (removedConnection, removedJob) = + manager.removeConnectionIfMatches(macAddress, initialJob) + + assertNull(removedConnection) + assertNull(removedJob) + assertEquals(replacementConnection, manager.getConnection(macAddress)) + } finally { + initialJob.cancel() + replacementJob.cancel() + } + } + + @Test + fun `removeConnectionIfMatches removes when job matches`() { + val manager = DeviceConnectionManager() + val macAddress = "AA:BB:CC:DD:EE:FF" + val connection = mockk(relaxed = true) + val job = Job() + + try { + manager.addConnection(macAddress, connection, job) + + val (removedConnection, removedJob) = manager.removeConnectionIfMatches(macAddress, job) + + assertEquals(connection, removedConnection) + assertEquals(job, removedJob) + assertNull(manager.getConnection(macAddress)) + } finally { + job.cancel() + } + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorFirmwareTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorFirmwareTest.kt index 74d512b..8bb3827 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorFirmwareTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorFirmwareTest.kt @@ -6,18 +6,26 @@ import android.app.PendingIntent import android.content.Context import androidx.core.app.NotificationManagerCompat import dev.sebastiano.camerasync.CameraSyncApp +import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.PairedDevice +import dev.sebastiano.camerasync.domain.vendor.CameraVendor import dev.sebastiano.camerasync.domain.vendor.DefaultConnectionDelegate +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.SyncCapabilities import dev.sebastiano.camerasync.domain.vendor.VendorConnectionDelegate import dev.sebastiano.camerasync.fakes.FakeCameraConnection import dev.sebastiano.camerasync.fakes.FakeCameraRepository import dev.sebastiano.camerasync.fakes.FakeCameraVendor import dev.sebastiano.camerasync.fakes.FakeDeviceNameProvider +import dev.sebastiano.camerasync.fakes.FakeGattSpec import dev.sebastiano.camerasync.fakes.FakeIntentFactory import dev.sebastiano.camerasync.fakes.FakeKhronicleLogger import dev.sebastiano.camerasync.fakes.FakeLocationCollector import dev.sebastiano.camerasync.fakes.FakePairedDevicesRepository import dev.sebastiano.camerasync.fakes.FakePendingIntentFactory +import dev.sebastiano.camerasync.fakes.FakeProtocol +import dev.sebastiano.camerasync.fakes.FakeRemoteControlDelegate import dev.sebastiano.camerasync.fakes.FakeVendorRegistry import dev.sebastiano.camerasync.firmware.FirmwareUpdateScheduler import io.mockk.every @@ -66,7 +74,7 @@ class MultiDeviceSyncCoordinatorFirmwareTest { ) private fun PairedDevice.toTestCamera() = - dev.sebastiano.camerasync.domain.model.Camera( + Camera( identifier = macAddress, name = name, macAddress = macAddress, @@ -97,7 +105,7 @@ class MultiDeviceSyncCoordinatorFirmwareTest { connectionManager = DeviceConnectionManager() val notificationBuilder = - object : dev.sebastiano.camerasync.devicesync.NotificationBuilder { + object : NotificationBuilder { override fun build( channelId: String, title: String, @@ -107,7 +115,7 @@ class MultiDeviceSyncCoordinatorFirmwareTest { priority: Int, category: String?, isSilent: Boolean, - actions: List, + actions: List, contentIntent: PendingIntent?, ): Notification = mockk(relaxed = true) } @@ -207,11 +215,11 @@ class MultiDeviceSyncCoordinatorFirmwareTest { fun `firmware update notification not shown when firmware version is null`() = testScope.runTest { val noFirmwareVendor = - object : dev.sebastiano.camerasync.domain.vendor.CameraVendor { + object : CameraVendor { override val vendorId: String = "no-fw" override val vendorName: String = "No FW" - override val gattSpec = dev.sebastiano.camerasync.fakes.FakeGattSpec - override val protocol = dev.sebastiano.camerasync.fakes.FakeProtocol + override val gattSpec = FakeGattSpec + override val protocol = FakeProtocol override fun recognizesDevice( deviceName: String?, @@ -222,8 +230,10 @@ class MultiDeviceSyncCoordinatorFirmwareTest { override fun createConnectionDelegate(): VendorConnectionDelegate = DefaultConnectionDelegate() - override fun getCapabilities() = - dev.sebastiano.camerasync.domain.vendor.CameraCapabilities( + override fun getRemoteControlCapabilities() = RemoteControlCapabilities() + + override fun getSyncCapabilities() = + SyncCapabilities( supportsFirmwareVersion = false, supportsDeviceName = true, supportsDateTimeSync = true, @@ -233,6 +243,11 @@ class MultiDeviceSyncCoordinatorFirmwareTest { override fun extractModelFromPairingName(pairingName: String?) = pairingName ?: "No FW" + + override fun createRemoteControlDelegate( + peripheral: com.juul.kable.Peripheral, + camera: Camera, + ): RemoteControlDelegate = FakeRemoteControlDelegate() } vendorRegistry.addVendor(noFirmwareVendor) @@ -241,7 +256,7 @@ class MultiDeviceSyncCoordinatorFirmwareTest { val connection = FakeCameraConnection( - dev.sebastiano.camerasync.domain.model.Camera( + Camera( identifier = noFwDevice.macAddress, name = noFwDevice.name, macAddress = noFwDevice.macAddress, @@ -303,11 +318,11 @@ class MultiDeviceSyncCoordinatorFirmwareTest { fun `initial setup respects capabilities`() = testScope.runTest { val limitedVendor = - object : dev.sebastiano.camerasync.domain.vendor.CameraVendor { + object : CameraVendor { override val vendorId: String = "limited" override val vendorName: String = "Limited" - override val gattSpec = dev.sebastiano.camerasync.fakes.FakeGattSpec - override val protocol = dev.sebastiano.camerasync.fakes.FakeProtocol + override val gattSpec = FakeGattSpec + override val protocol = FakeProtocol override fun recognizesDevice( deviceName: String?, @@ -318,8 +333,10 @@ class MultiDeviceSyncCoordinatorFirmwareTest { override fun createConnectionDelegate(): VendorConnectionDelegate = DefaultConnectionDelegate() - override fun getCapabilities() = - dev.sebastiano.camerasync.domain.vendor.CameraCapabilities( + override fun getRemoteControlCapabilities() = RemoteControlCapabilities() + + override fun getSyncCapabilities() = + SyncCapabilities( supportsFirmwareVersion = false, supportsDeviceName = false, supportsDateTimeSync = false, @@ -329,6 +346,11 @@ class MultiDeviceSyncCoordinatorFirmwareTest { override fun extractModelFromPairingName(pairingName: String?) = pairingName ?: "Limited" + + override fun createRemoteControlDelegate( + peripheral: com.juul.kable.Peripheral, + camera: Camera, + ): RemoteControlDelegate = FakeRemoteControlDelegate() } vendorRegistry.addVendor(limitedVendor) @@ -337,7 +359,7 @@ class MultiDeviceSyncCoordinatorFirmwareTest { testDevice1.copy(vendorId = "limited", macAddress = "FF:FF:FF:FF:FF:FF") val connection = FakeCameraConnection( - dev.sebastiano.camerasync.domain.model.Camera( + Camera( identifier = limitedDevice.macAddress, name = limitedDevice.name, macAddress = limitedDevice.macAddress, diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorTest.kt index 1b09ed5..7b1f12d 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/MultiDeviceSyncCoordinatorTest.kt @@ -7,6 +7,7 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import dev.sebastiano.camerasync.CameraSyncApp import dev.sebastiano.camerasync.R +import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.DeviceConnectionState import dev.sebastiano.camerasync.domain.model.GpsLocation import dev.sebastiano.camerasync.domain.model.PairedDevice @@ -931,6 +932,21 @@ class MultiDeviceSyncCoordinatorTest { assertTrue(present.contains(testDevice1.macAddress)) } + @Test + fun `stopDeviceSync removes device from present devices`() = + testScope.runTest { + val connection = FakeCameraConnection(testDevice1.toTestCamera()) + cameraRepository.connectionToReturn = connection + + coordinator.startDeviceSync(testDevice1) + advanceUntilIdle() + + coordinator.stopDeviceSync(testDevice1.macAddress, awaitCompletion = true) + advanceUntilIdle() + + assertFalse(coordinator.presentDevices.value.contains(testDevice1.macAddress)) + } + @Test fun `initial setup failures do not abort connection if partial success`() = testScope.runTest { @@ -1005,10 +1021,11 @@ class MultiDeviceSyncCoordinatorTest { fun `connection is disconnected if performInitialSetup fails`() = testScope.runTest { // We need performInitialSetup to throw. - // One way is to make getCapabilities throw. + // One way is to make getSyncCapabilities throw. val throwingVendor = mockk() every { throwingVendor.vendorId } returns "throwing" - every { throwingVendor.getCapabilities() } throws IllegalStateException("Setup failed") + every { throwingVendor.getSyncCapabilities() } throws + IllegalStateException("Setup failed") vendorRegistry.addVendor(throwingVendor) val throwingDevice = testDevice1.copy(vendorId = "throwing") @@ -1031,7 +1048,7 @@ class MultiDeviceSyncCoordinatorTest { } private fun PairedDevice.toTestCamera() = - dev.sebastiano.camerasync.domain.model.Camera( + Camera( identifier = macAddress, name = name, macAddress = macAddress, diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/NotificationsTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/NotificationsTest.kt index b5c4517..399ad95 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/NotificationsTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/devicesync/NotificationsTest.kt @@ -7,6 +7,7 @@ import android.content.res.Resources import androidx.core.app.NotificationCompat import dev.sebastiano.camerasync.CameraSyncApp import dev.sebastiano.camerasync.R +import dev.sebastiano.camerasync.di.TestGraph import dev.sebastiano.camerasync.di.TestGraphFactory import dev.sebastiano.camerasync.fakes.FakeIntentFactory import dev.sebastiano.camerasync.fakes.FakeKhronicleLogger @@ -32,7 +33,7 @@ class NotificationsTest { private lateinit var context: Context private lateinit var notificationManager: NotificationManager - private lateinit var testGraph: dev.sebastiano.camerasync.di.TestGraph + private lateinit var testGraph: TestGraph // Store fake instances to avoid getting new instances on each testGraph.* access private lateinit var notificationBuilder: FakeNotificationBuilder @@ -347,15 +348,15 @@ class NotificationsTest { // Verify PendingIntentFactory was called 3 times (2 actions + 1 content intent) assertEquals(3, pendingIntentFactory.calls.size) assertEquals( - dev.sebastiano.camerasync.devicesync.MultiDeviceSyncService.REFRESH_REQUEST_CODE, + MultiDeviceSyncService.REFRESH_REQUEST_CODE, pendingIntentFactory.calls[0].requestCode, ) assertEquals( - dev.sebastiano.camerasync.devicesync.MultiDeviceSyncService.STOP_REQUEST_CODE, + MultiDeviceSyncService.STOP_REQUEST_CODE, pendingIntentFactory.calls[1].requestCode, ) assertEquals( - dev.sebastiano.camerasync.devicesync.MultiDeviceSyncService.MAIN_ACTIVITY_REQUEST_CODE, + MultiDeviceSyncService.MAIN_ACTIVITY_REQUEST_CODE, pendingIntentFactory.calls[2].requestCode, ) diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendorRegistryTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendorRegistryTest.kt index ce67d34..e26885b 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendorRegistryTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/CameraVendorRegistryTest.kt @@ -43,12 +43,13 @@ class CameraVendorRegistryTest { // identifyVendor tests @Test - fun `identifyVendor returns Ricoh for Ricoh service UUID`() { + fun `identifyVendor returns Ricoh for Ricoh manufacturer data`() { val vendor = registry.identifyVendor( deviceName = null, - serviceUuids = listOf(RicohGattSpec.SCAN_FILTER_SERVICE_UUID), - manufacturerData = emptyMap(), + serviceUuids = emptyList(), + manufacturerData = + mapOf(RicohGattSpec.RICOH_MANUFACTURER_ID to byteArrayOf(0xDA.toByte())), ) assertEquals(RicohCameraVendor, vendor) } @@ -171,9 +172,6 @@ class CameraVendorRegistryTest { fun `getAllScanFilterUuids returns UUIDs from all vendors`() { val uuids = registry.getAllScanFilterUuids() - // Should contain Ricoh UUIDs - assertTrue(uuids.contains(RicohGattSpec.SCAN_FILTER_SERVICE_UUID)) - // Should contain Sony UUIDs assertTrue(uuids.contains(SonyGattSpec.REMOTE_CONTROL_SERVICE_UUID)) assertTrue(uuids.contains(SonyGattSpec.PAIRING_SERVICE_UUID)) @@ -243,8 +241,9 @@ class CameraVendorRegistryTest { val vendor = emptyRegistry.identifyVendor( deviceName = "GR IIIx", - serviceUuids = listOf(RicohGattSpec.SCAN_FILTER_SERVICE_UUID), - manufacturerData = emptyMap(), + serviceUuids = emptyList(), + manufacturerData = + mapOf(RicohGattSpec.RICOH_MANUFACTURER_ID to byteArrayOf(0xDA.toByte())), ) assertNull(vendor) } diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegateTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegateTest.kt new file mode 100644 index 0000000..9f767b3 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/domain/vendor/RemoteControlDelegateTest.kt @@ -0,0 +1,71 @@ +package dev.sebastiano.camerasync.domain.vendor + +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.StorageInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.fail +import org.junit.Test + +class RemoteControlDelegateTest { + + private class MinimalDelegate : RemoteControlDelegate { + private val _connectionMode = MutableStateFlow(ShootingConnectionMode.BLE_ONLY) + override val connectionMode: StateFlow = + _connectionMode.asStateFlow() + + override suspend fun triggerCapture() = Unit + + override suspend fun startBulbExposure() = Unit + + override suspend fun stopBulbExposure() = Unit + + override fun observeBatteryLevel(): Flow = emptyFlow() + + override fun observeStorageStatus(): Flow = emptyFlow() + + override fun observeCameraMode(): Flow = emptyFlow() + + override fun observeCaptureStatus(): Flow = emptyFlow() + + override fun observeExposureMode(): Flow = emptyFlow() + + override fun observeDriveMode(): Flow = emptyFlow() + + override suspend fun connectWifi() = Unit + + override suspend fun disconnectWifi() = Unit + } + + @Test + fun `default optional methods throw UnsupportedOperationException`() = runTest { + val delegate = MinimalDelegate() + + assertThrowsUnsupported { delegate.toggleAELock() } + assertThrowsUnsupported { delegate.toggleFELock() } + assertThrowsUnsupported { delegate.toggleAWBLock() } + assertThrowsUnsupported { delegate.focusNear() } + assertThrowsUnsupported { delegate.focusFar() } + assertThrowsUnsupported { delegate.zoomIn() } + assertThrowsUnsupported { delegate.zoomOut() } + assertThrowsUnsupported { delegate.touchAF(0.5f, 0.5f) } + assertThrowsUnsupported { delegate.toggleVideoRecording() } + } + + private suspend fun assertThrowsUnsupported(block: suspend () -> Unit) { + try { + block() + fail("Expected UnsupportedOperationException") + } catch (_: UnsupportedOperationException) { + // expected + } + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeCameraConnection.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeCameraConnection.kt index d3bc844..56470ee 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeCameraConnection.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeCameraConnection.kt @@ -3,6 +3,7 @@ package dev.sebastiano.camerasync.fakes import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.GpsLocation import dev.sebastiano.camerasync.domain.repository.CameraConnection +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate import java.io.IOException import java.time.ZonedDateTime import kotlinx.coroutines.flow.Flow @@ -94,6 +95,20 @@ class FakeCameraConnection(override val camera: Camera) : CameraConnection { _isConnected.value = false } + override fun supportsLocationSync(): Boolean = + camera.vendor.getSyncCapabilities().supportsLocationSync + + /** + * Optional delegate for remote control tests. When set, [getRemoteControlDelegate] returns it. + */ + private var _remoteControlDelegate: RemoteControlDelegate? = null + + override fun getRemoteControlDelegate(): RemoteControlDelegate? = _remoteControlDelegate + + fun setRemoteControlDelegate(delegate: RemoteControlDelegate?) { + _remoteControlDelegate = delegate + } + fun setConnected(connected: Boolean) { _isConnected.value = connected } diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeIntentFactory.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeIntentFactory.kt index 9412734..fda35b1 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeIntentFactory.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeIntentFactory.kt @@ -10,6 +10,12 @@ class FakeIntentFactory : IntentFactory { var lastRefreshIntent: Intent? = null private set + var lastRefreshDeviceIntent: Intent? = null + private set + + var lastRefreshDeviceMacAddress: String? = null + private set + var lastStopIntent: Intent? = null private set @@ -22,6 +28,8 @@ class FakeIntentFactory : IntentFactory { /** Reset the factory state between tests. */ fun reset() { lastRefreshIntent = null + lastRefreshDeviceIntent = null + lastRefreshDeviceMacAddress = null lastStopIntent = null lastStartIntent = null lastMainActivityIntent = null @@ -34,6 +42,14 @@ class FakeIntentFactory : IntentFactory { return intent } + override fun createRefreshDeviceIntent(context: Context, macAddress: String): Intent { + // Context is passed but not used in fake - just track the call + val intent = mockk(relaxed = true) + lastRefreshDeviceIntent = intent + lastRefreshDeviceMacAddress = macAddress + return intent + } + override fun createStopIntent(context: Context): Intent { // Context is passed but not used in fake - just track the call val intent = mockk(relaxed = true) diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeRemoteControlDelegate.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeRemoteControlDelegate.kt new file mode 100644 index 0000000..3edbdfd --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeRemoteControlDelegate.kt @@ -0,0 +1,44 @@ +package dev.sebastiano.camerasync.fakes + +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.StorageInfo +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow + +/** Fake [RemoteControlDelegate] for tests. */ +class FakeRemoteControlDelegate : RemoteControlDelegate { + + private val _connectionMode = MutableStateFlow(ShootingConnectionMode.BLE_ONLY) + override val connectionMode: StateFlow = _connectionMode.asStateFlow() + + override suspend fun triggerCapture() = Unit + + override suspend fun startBulbExposure() = Unit + + override suspend fun stopBulbExposure() = Unit + + override fun observeBatteryLevel(): Flow = emptyFlow() + + override fun observeStorageStatus(): Flow = emptyFlow() + + override fun observeCameraMode(): Flow = emptyFlow() + + override fun observeCaptureStatus(): Flow = emptyFlow() + + override fun observeExposureMode(): Flow = emptyFlow() + + override fun observeDriveMode(): Flow = emptyFlow() + + override suspend fun connectWifi() = Unit + + override suspend fun disconnectWifi() = Unit +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeVendorRegistry.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeVendorRegistry.kt index 882cbe5..982c4a8 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeVendorRegistry.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/FakeVendorRegistry.kt @@ -1,12 +1,16 @@ package dev.sebastiano.camerasync.fakes +import com.juul.kable.Peripheral +import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.GpsLocation -import dev.sebastiano.camerasync.domain.vendor.CameraCapabilities import dev.sebastiano.camerasync.domain.vendor.CameraGattSpec import dev.sebastiano.camerasync.domain.vendor.CameraProtocol import dev.sebastiano.camerasync.domain.vendor.CameraVendor import dev.sebastiano.camerasync.domain.vendor.CameraVendorRegistry import dev.sebastiano.camerasync.domain.vendor.DefaultConnectionDelegate +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.SyncCapabilities import dev.sebastiano.camerasync.domain.vendor.VendorConnectionDelegate import java.time.ZonedDateTime import kotlin.uuid.ExperimentalUuidApi @@ -67,8 +71,16 @@ object FakeCameraVendor : CameraVendor { override fun createConnectionDelegate(): VendorConnectionDelegate = DefaultConnectionDelegate() - override fun getCapabilities(): CameraCapabilities = - CameraCapabilities( + override fun createRemoteControlDelegate( + peripheral: Peripheral, + camera: Camera, + ): RemoteControlDelegate = FakeRemoteControlDelegate() + + override fun getRemoteControlCapabilities(): RemoteControlCapabilities = + RemoteControlCapabilities() + + override fun getSyncCapabilities(): SyncCapabilities = + SyncCapabilities( supportsFirmwareVersion = true, supportsDeviceName = true, supportsDateTimeSync = true, diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/ThrowingRemoteControlDelegate.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/ThrowingRemoteControlDelegate.kt new file mode 100644 index 0000000..21464f7 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/fakes/ThrowingRemoteControlDelegate.kt @@ -0,0 +1,24 @@ +package dev.sebastiano.camerasync.fakes + +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate + +/** + * A [RemoteControlDelegate] that throws from [triggerCapture] and [disconnectWifi] to simulate BLE + * failures (e.g. camera disconnected). Used for regression tests that verify the ViewModel catches + * these exceptions and does not crash. + */ +class ThrowingRemoteControlDelegate( + private val triggerCaptureError: Throwable = + java.io.IOException("BLE write failed (disconnected)"), + private val disconnectWifiError: Throwable = + java.io.IOException("BLE write failed (disconnected)"), +) : RemoteControlDelegate by FakeRemoteControlDelegate() { + + override suspend fun triggerCapture() { + throw triggerCaptureError + } + + override suspend fun disconnectWifi() { + throw disconnectWifiError + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/testutils/WriteRecorder.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/testutils/WriteRecorder.kt new file mode 100644 index 0000000..a1a6ea7 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/testutils/WriteRecorder.kt @@ -0,0 +1,92 @@ +package dev.sebastiano.camerasync.testutils + +import com.juul.kable.Characteristic +import com.juul.kable.WriteType +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) + +/** + * Records BLE writes in tests for later assertion. + * + * Use with a mocked [Peripheral][com.juul.kable.Peripheral]: pass a [WriteRecorder] and in the + * mock's `coEvery { write(any(), any(), any()) } answers { ... }` call [record] with + * [firstArg][io.mockk.MockKMatcherScope.firstArg], [secondArg], [thirdArg]. + * + * Supports both 2-arg and 3-arg [record] for tests that do not care about [WriteType]. + */ +class WriteRecorder { + + private data class Entry( + val characteristicUuid: Uuid, + val data: ByteArray, + val writeType: WriteType?, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Entry) return false + if (characteristicUuid != other.characteristicUuid) return false + if (!data.contentEquals(other.data)) return false + if (writeType != other.writeType) return false + return true + } + + override fun hashCode(): Int { + var result = characteristicUuid.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + (writeType?.hashCode() ?: 0) + return result + } + } + + private val writes = mutableListOf() + + /** Records a write. Use when the mock captures (Characteristic, ByteArray, WriteType). */ + fun record(characteristic: Characteristic, data: ByteArray, writeType: WriteType?) { + writes.add( + Entry( + characteristicUuid = characteristic.characteristicUuid, + data = data.copyOf(), + writeType = writeType, + ) + ) + } + + /** + * Records a write without WriteType. Use when the mock only captures (Characteristic, + * ByteArray). + */ + fun record(characteristic: Characteristic, data: ByteArray) { + record(characteristic, data, null) + } + + /** All data payloads written to the given characteristic, in order. */ + fun dataFor(characteristicUuid: Uuid): List = + writes.filter { it.characteristicUuid == characteristicUuid }.map { it.data } + + /** + * All write types for the given characteristic (only entries that had a non-null type), in + * order. + */ + fun writeTypesFor(characteristicUuid: Uuid): List = + writes + .filter { it.characteristicUuid == characteristicUuid && it.writeType != null } + .map { it.writeType!! } + + fun hasWriteTo(characteristicUuid: Uuid): Boolean = + writes.any { it.characteristicUuid == characteristicUuid } + + fun hasWriteToWithData(characteristicUuid: Uuid, data: ByteArray): Boolean = + writes.any { it.characteristicUuid == characteristicUuid && it.data.contentEquals(data) } + + /** Last data written to the given characteristic, or null. */ + fun dataWrittenTo(characteristicUuid: Uuid): ByteArray? = + writes.lastOrNull { it.characteristicUuid == characteristicUuid }?.data + + fun countWritesWithData(characteristicUuid: Uuid, data: ByteArray): Int = + writes.count { it.characteristicUuid == characteristicUuid && it.data.contentEquals(data) } + + fun getAllWrites(): List> = + writes.map { it.characteristicUuid to it.data } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingActionBannerTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingActionBannerTest.kt new file mode 100644 index 0000000..8ddd109 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingActionBannerTest.kt @@ -0,0 +1,44 @@ +package dev.sebastiano.camerasync.ui.remote + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import dev.sebastiano.camerasync.ui.theme.CameraSyncTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class RemoteShootingActionBannerTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun actionBanner_showsRetryResetAndDismiss() { + val errorState = + RemoteShootingActionState.Error( + action = RemoteShootingAction.TriggerCapture, + message = "Action failed. Please try again.", + canRetry = true, + canReset = true, + ) + + composeTestRule.setContent { + CameraSyncTheme { + RemoteShootingActionBanner( + actionState = errorState, + onRetryAction = {}, + onResetRemoteShooting = {}, + onDismissActionError = {}, + ) + } + } + + composeTestRule.onNodeWithText("Action failed. Please try again.").assertExists() + composeTestRule.onNodeWithText("Retry").assertExists() + composeTestRule.onNodeWithText("Reset remote shooting").assertExists() + composeTestRule.onNodeWithText("OK").assertExists() + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingStatusBarTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingStatusBarTest.kt new file mode 100644 index 0000000..6d9e56f --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingStatusBarTest.kt @@ -0,0 +1,82 @@ +package dev.sebastiano.camerasync.ui.remote + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import dev.sebastiano.camerasync.domain.model.BatteryInfo +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.model.StorageInfo +import dev.sebastiano.camerasync.domain.vendor.BatteryMonitoringCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import dev.sebastiano.camerasync.domain.vendor.StorageMonitoringCapabilities +import dev.sebastiano.camerasync.ui.theme.CameraSyncTheme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class RemoteShootingStatusBarTest { + + @get:Rule val composeTestRule = createComposeRule() + + private class ThrowingStatusDelegate : RemoteControlDelegate { + private val _connectionMode = MutableStateFlow(ShootingConnectionMode.BLE_ONLY) + override val connectionMode: StateFlow = + _connectionMode.asStateFlow() + + override suspend fun triggerCapture() = Unit + + override suspend fun startBulbExposure() = Unit + + override suspend fun stopBulbExposure() = Unit + + override fun observeBatteryLevel(): Flow = flow { + throw IllegalStateException("Boom") + } + + override fun observeStorageStatus(): Flow = flow { + throw IllegalStateException("Boom") + } + + override fun observeCameraMode(): Flow = emptyFlow() + + override fun observeCaptureStatus(): Flow = emptyFlow() + + override fun observeExposureMode(): Flow = emptyFlow() + + override fun observeDriveMode(): Flow = emptyFlow() + + override suspend fun connectWifi() = Unit + + override suspend fun disconnectWifi() = Unit + } + + @Test + fun statusBar_handlesFlowFailures() { + val capabilities = + RemoteControlCapabilities( + batteryMonitoring = BatteryMonitoringCapabilities(supported = true), + storageMonitoring = StorageMonitoringCapabilities(supported = true), + ) + + composeTestRule.setContent { + CameraSyncTheme { StatusBar(capabilities, ThrowingStatusDelegate()) } + } + + composeTestRule.onNodeWithText("Battery: --").assertExists() + composeTestRule.onNodeWithText("Storage: No card").assertExists() + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModelTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModelTest.kt new file mode 100644 index 0000000..9fdca75 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/ui/remote/RemoteShootingViewModelTest.kt @@ -0,0 +1,221 @@ +package dev.sebastiano.camerasync.ui.remote + +import dev.sebastiano.camerasync.CameraSyncApp +import dev.sebastiano.camerasync.devicesync.DeviceConnectionManager +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.vendor.CameraVendor +import dev.sebastiano.camerasync.domain.vendor.RemoteControlCapabilities +import dev.sebastiano.camerasync.domain.vendor.RemoteControlDelegate +import dev.sebastiano.camerasync.domain.vendor.SyncCapabilities +import dev.sebastiano.camerasync.fakes.FakeCameraConnection +import dev.sebastiano.camerasync.fakes.FakeIntentFactory +import dev.sebastiano.camerasync.fakes.FakeKhronicleLogger +import dev.sebastiano.camerasync.fakes.FakePairedDevicesRepository +import dev.sebastiano.camerasync.fakes.FakeRemoteControlDelegate +import dev.sebastiano.camerasync.fakes.ThrowingRemoteControlDelegate +import io.mockk.every +import io.mockk.mockk +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** Regression tests for [RemoteShootingViewModel] remote control exception handling. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteShootingViewModelTest { + + private val macAddress = "11:22:33:44:55:66" + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var pairedDevicesRepository: FakePairedDevicesRepository + private lateinit var connectionManager: DeviceConnectionManager + private lateinit var intentFactory: FakeIntentFactory + private lateinit var viewModel: RemoteShootingViewModel + private lateinit var connection: FakeCameraConnection + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + CameraSyncApp.initializeLogging(FakeKhronicleLogger) + + pairedDevicesRepository = FakePairedDevicesRepository() + connectionManager = DeviceConnectionManager() + intentFactory = FakeIntentFactory() + + val vendor = + mockk(relaxed = true) { + every { vendorId } returns "test" + every { getRemoteControlCapabilities() } returns RemoteControlCapabilities() + every { getSyncCapabilities() } returns SyncCapabilities() + } + val camera = + Camera( + identifier = "test-id", + name = "Test Camera", + macAddress = macAddress, + vendor = vendor, + ) + + runTest(testDispatcher) { pairedDevicesRepository.addDevice(camera, enabled = true) } + + connection = FakeCameraConnection(camera) + connection.setRemoteControlDelegate(ThrowingRemoteControlDelegate()) + connectionManager.addConnection(macAddress, connection, Job()) + + viewModel = + RemoteShootingViewModel( + deviceConnectionManager = connectionManager, + pairedDevicesRepository = pairedDevicesRepository, + intentFactory = intentFactory, + context = mockk(relaxed = true), + ioDispatcher = testDispatcher, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `loadDevice reaches Ready state when device and connection exist`() = + runTest(testDispatcher) { + viewModel.loadDevice(macAddress) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is RemoteShootingUiState.Ready) + assertNotNull((state as RemoteShootingUiState.Ready).delegate) + } + + @Test + fun `triggerCapture does not throw when delegate throws`() = + runTest(testDispatcher) { + viewModel.loadDevice(macAddress) + advanceUntilIdle() + assertTrue(viewModel.uiState.value is RemoteShootingUiState.Ready) + + viewModel.triggerCapture() + advanceUntilIdle() + val state = viewModel.uiState.value as RemoteShootingUiState.Ready + val actionState = state.actionState + assertTrue(actionState is RemoteShootingActionState.Error) + actionState as RemoteShootingActionState.Error + assertTrue(actionState.canRetry) + assertTrue(actionState.canReset) + } + + @Test + fun `disconnectWifi does not throw when delegate throws`() = + runTest(testDispatcher) { + viewModel.loadDevice(macAddress) + advanceUntilIdle() + assertTrue(viewModel.uiState.value is RemoteShootingUiState.Ready) + + viewModel.disconnectWifi() + advanceUntilIdle() + val state = viewModel.uiState.value as RemoteShootingUiState.Ready + val actionState = state.actionState + assertTrue(actionState is RemoteShootingActionState.Error) + actionState as RemoteShootingActionState.Error + assertTrue(actionState.canRetry) + assertTrue(actionState.canReset) + } + + @Test + fun `retryAction reattempts failed capture and clears error`() = + runTest(testDispatcher) { + val delegate = FlakyRemoteControlDelegate() + connection.setRemoteControlDelegate(delegate) + + viewModel.loadDevice(macAddress) + advanceUntilIdle() + + viewModel.triggerCapture() + advanceUntilIdle() + + val errorState = viewModel.uiState.value as RemoteShootingUiState.Ready + assertTrue(errorState.actionState is RemoteShootingActionState.Error) + + viewModel.retryAction(RemoteShootingAction.TriggerCapture) + advanceUntilIdle() + + val readyState = viewModel.uiState.value as RemoteShootingUiState.Ready + assertTrue(readyState.actionState is RemoteShootingActionState.Idle) + assertTrue(delegate.calls == 2) + } + + @Test + fun `resetRemoteShooting requests refresh intent for device`() = + runTest(testDispatcher) { + viewModel.loadDevice(macAddress) + advanceUntilIdle() + + viewModel.resetRemoteShooting() + advanceUntilIdle() + + assertNotNull(intentFactory.lastRefreshDeviceIntent) + assertTrue(intentFactory.lastRefreshDeviceMacAddress == macAddress) + } + + @Test + fun `after BLE reconnect state is refreshed with new delegate`() = + runTest(testDispatcher) { + viewModel.loadDevice(macAddress) + advanceUntilIdle() + val firstReady = viewModel.uiState.value as RemoteShootingUiState.Ready + val firstDelegate = firstReady.delegate + + // Simulate BLE disconnect then reconnect (new connection instance) + connectionManager.removeConnection(macAddress) + advanceUntilIdle() + assertTrue(viewModel.uiState.value is RemoteShootingUiState.Error) + + val vendor2 = + mockk(relaxed = true) { + every { vendorId } returns "test" + every { getRemoteControlCapabilities() } returns RemoteControlCapabilities() + every { getSyncCapabilities() } returns SyncCapabilities() + } + val camera2 = + Camera( + identifier = "test-id-2", + name = "Test Camera", + macAddress = macAddress, + vendor = vendor2, + ) + val connection2 = FakeCameraConnection(camera2) + connection2.setRemoteControlDelegate(FakeRemoteControlDelegate()) + connectionManager.addConnection(macAddress, connection2, Job()) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is RemoteShootingUiState.Ready) + val secondReady = viewModel.uiState.value as RemoteShootingUiState.Ready + assertTrue( + "Delegate should be the new instance after reconnect", + secondReady.delegate !== firstDelegate, + ) + } + + private class FlakyRemoteControlDelegate : + RemoteControlDelegate by FakeRemoteControlDelegate() { + var calls = 0 + + override suspend fun triggerCapture() { + calls += 1 + if (calls == 1) { + throw IOException("BLE write failed") + } + } + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendorTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendorTest.kt index 2483273..5a85514 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendorTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohCameraVendorTest.kt @@ -23,17 +23,76 @@ class RicohCameraVendorTest { } @Test - fun `recognizes device with Ricoh service UUID`() { - val serviceUuids = listOf(RicohGattSpec.SCAN_FILTER_SERVICE_UUID) - assertTrue(RicohCameraVendor.recognizesDevice("GR IIIx", serviceUuids, emptyMap())) + fun `recognizes device with Ricoh manufacturer data`() { + val manufacturerData = + mapOf(RicohGattSpec.RICOH_MANUFACTURER_ID to byteArrayOf(0xDA.toByte())) + assertTrue(RicohCameraVendor.recognizesDevice("GR IIIx", emptyList(), manufacturerData)) } @Test - fun `recognizes device with Ricoh service UUID regardless of device name`() { - val serviceUuids = listOf(RicohGattSpec.SCAN_FILTER_SERVICE_UUID) - assertTrue(RicohCameraVendor.recognizesDevice(null, serviceUuids, emptyMap())) - assertTrue(RicohCameraVendor.recognizesDevice("", serviceUuids, emptyMap())) - assertTrue(RicohCameraVendor.recognizesDevice("Unknown Camera", serviceUuids, emptyMap())) + fun `recognizes device with Ricoh manufacturer data regardless of device name`() { + val manufacturerData = + mapOf(RicohGattSpec.RICOH_MANUFACTURER_ID to byteArrayOf(0xDA.toByte())) + assertTrue(RicohCameraVendor.recognizesDevice(null, emptyList(), manufacturerData)) + assertTrue(RicohCameraVendor.recognizesDevice("", emptyList(), manufacturerData)) + assertTrue( + RicohCameraVendor.recognizesDevice("Unknown Camera", emptyList(), manufacturerData) + ) + } + + @Test + fun `parseAdvertisementMetadata extracts model code, serial, and power`() { + val payload = + byteArrayOf( + 0xDA.toByte(), + 0x01, + 0x01, + 0x03, // model code + 0x02, + 0x04, + 0x01, + 0x02, + 0x03, + 0x04, // serial + 0x03, + 0x01, + 0x01, // power on + ) + val metadata = + RicohCameraVendor.parseAdvertisementMetadata( + mapOf(RicohGattSpec.RICOH_MANUFACTURER_ID to payload) + ) + assertEquals(3, metadata["modelCode"]) + assertEquals("01020304", metadata["serial"]) + assertEquals(1, metadata["cameraPower"]) + } + + @Test + fun `parseAdvertisementMetadata handles unsigned bytes correctly`() { + // Serial bytes above 0x7F must not be sign-extended when formatting hex + val payload = + byteArrayOf( + 0xDA.toByte(), + 0x01, + 0x01, + 0xAB.toByte(), // model code (high byte) + 0x02, + 0x04, + 0xDE.toByte(), + 0xAD.toByte(), + 0xBE.toByte(), + 0xEF.toByte(), // serial (high bytes) + 0x03, + 0x01, + 0xFF.toByte(), // power (high byte) + ) + val metadata = + RicohCameraVendor.parseAdvertisementMetadata( + mapOf(RicohGattSpec.RICOH_MANUFACTURER_ID to payload) + ) + assertEquals(0xAB, metadata["modelCode"]) + assertEquals("deadbeef", metadata["serial"]) + assertEquals(0xFF, metadata["cameraPower"]) } @Test @@ -55,6 +114,12 @@ class RicohCameraVendorTest { ) // case-insensitive } + @Test + fun `recognizes device by Ricoh service UUID even without name or manufacturer data`() { + val serviceUuids = listOf(RicohGattSpec.Firmware.SERVICE_UUID) + assertTrue(RicohCameraVendor.recognizesDevice(null, serviceUuids, emptyMap())) + } + @Test fun `does not recognize device without service UUID or recognized name`() { assertFalse(RicohCameraVendor.recognizesDevice("Unknown Camera", emptyList(), emptyMap())) @@ -70,32 +135,32 @@ class RicohCameraVendorTest { @Test fun `capabilities indicate firmware version support`() { - assertTrue(RicohCameraVendor.getCapabilities().supportsFirmwareVersion) + assertTrue(RicohCameraVendor.getSyncCapabilities().supportsFirmwareVersion) } @Test fun `capabilities indicate device name support`() { - assertTrue(RicohCameraVendor.getCapabilities().supportsDeviceName) + assertTrue(RicohCameraVendor.getSyncCapabilities().supportsDeviceName) } @Test fun `capabilities indicate date time sync support`() { - assertTrue(RicohCameraVendor.getCapabilities().supportsDateTimeSync) + assertTrue(RicohCameraVendor.getSyncCapabilities().supportsDateTimeSync) } @Test fun `capabilities indicate geo tagging support`() { - assertTrue(RicohCameraVendor.getCapabilities().supportsGeoTagging) + assertTrue(RicohCameraVendor.getSyncCapabilities().supportsGeoTagging) } @Test fun `capabilities indicate location sync support`() { - assertTrue(RicohCameraVendor.getCapabilities().supportsLocationSync) + assertTrue(RicohCameraVendor.getSyncCapabilities().supportsLocationSync) } @Test fun `capabilities indicate hardware revision support`() { - assertTrue(RicohCameraVendor.getCapabilities().supportsHardwareRevision) + assertTrue(RicohCameraVendor.getSyncCapabilities().supportsHardwareRevision) } @Test diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpecTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpecTest.kt index 62c1c00..0798e38 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpecTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohGattSpecTest.kt @@ -9,20 +9,8 @@ import org.junit.Test class RicohGattSpecTest { @Test - fun `scan filter service UUID matches specification`() { - assertEquals( - Uuid.parse("84A0DD62-E8AA-4D0F-91DB-819B6724C69E"), - RicohGattSpec.SCAN_FILTER_SERVICE_UUID, - ) - } - - @Test - fun `scan filter service UUIDs list contains scan filter UUID`() { - assertEquals(1, RicohGattSpec.scanFilterServiceUuids.size) - assertEquals( - RicohGattSpec.SCAN_FILTER_SERVICE_UUID, - RicohGattSpec.scanFilterServiceUuids[0], - ) + fun `scan filter service UUIDs list is empty for Ricoh`() { + assertEquals(0, RicohGattSpec.scanFilterServiceUuids.size) } @Test @@ -31,6 +19,15 @@ class RicohGattSpecTest { assertEquals(listOf("GR", "RICOH"), RicohGattSpec.scanFilterDeviceNames) } + @Test + fun `scan filter manufacturer data includes Ricoh company id and prefix`() { + assertEquals(1, RicohGattSpec.scanFilterManufacturerData.size) + val filter = RicohGattSpec.scanFilterManufacturerData.first() + assertEquals(0x065F, filter.manufacturerId) + assertEquals(byteArrayOf(0xDA.toByte()).toList(), filter.data.toList()) + assertEquals(byteArrayOf(0xFF.toByte()).toList(), filter.mask?.toList()) + } + @Test fun `firmware service UUID matches specification`() { assertEquals( @@ -164,9 +161,79 @@ class RicohGattSpecTest { } @Test - fun `location service UUID matches scan filter service UUID`() { - // In Ricoh's implementation, the location service and scan filter use the same UUID - assertEquals(RicohGattSpec.SCAN_FILTER_SERVICE_UUID, RicohGattSpec.Location.SERVICE_UUID) + fun `Shooting service and Operation Request characteristic match dm-zharov spec`() { + assertEquals( + Uuid.parse("9f00f387-8345-4bbc-8b92-b87b52e3091a"), + RicohGattSpec.Shooting.SERVICE_UUID, + ) + assertEquals( + Uuid.parse("559644b8-e0bc-4011-929b-5cf9199851e7"), + RicohGattSpec.Shooting.OPERATION_REQUEST_CHARACTERISTIC_UUID, + ) + } + + @Test + fun `Shooting service characteristics match dm-zharov spec`() { + assertEquals( + Uuid.parse("a3c51525-de3e-4777-a1c2-699e28736fcf"), + RicohGattSpec.Shooting.SHOOTING_MODE_CHARACTERISTIC_UUID, + ) + assertEquals( + Uuid.parse("78009238-ac3d-4370-9b6f-c9ce2f4e3ca8"), + RicohGattSpec.Shooting.CAPTURE_MODE_CHARACTERISTIC_UUID, + ) + assertEquals( + Uuid.parse("b29e6de3-1aec-48c1-9d05-02cea57ce664"), + RicohGattSpec.Shooting.DRIVE_MODE_CHARACTERISTIC_UUID, + ) + assertEquals( + Uuid.parse("b5589c08-b5fd-46f5-be7d-ab1b8c074caa"), + RicohGattSpec.Shooting.CAPTURE_STATUS_CHARACTERISTIC_UUID, + ) + } + + @Test + fun `WlanControl service and Network Type characteristic match dm-zharov spec`() { + assertEquals( + Uuid.parse("f37f568f-9071-445d-a938-5441f2e82399"), + RicohGattSpec.WlanControl.SERVICE_UUID, + ) + assertEquals( + Uuid.parse("9111cdd0-9f01-45c4-a2d4-e09e8fb0424d"), + RicohGattSpec.WlanControl.NETWORK_TYPE_CHARACTERISTIC_UUID, + ) + } + + @Test + fun `CameraState Camera Power characteristic matches dm-zharov spec`() { + assertEquals( + Uuid.parse("b58ce84c-0666-4de9-bec8-2d27b27b3211"), + RicohGattSpec.CameraState.CAMERA_POWER_CHARACTERISTIC_UUID, + ) + } + + @Test + fun `CameraState Battery Level characteristic matches dm-zharov spec`() { + assertEquals( + Uuid.parse("875fc41d-4980-434c-a653-fd4a4d4410c4"), + RicohGattSpec.CameraState.BATTERY_LEVEL_CHARACTERISTIC_UUID, + ) + } + + @Test + fun `CameraState Storage Info characteristic matches dm-zharov spec`() { + assertEquals( + Uuid.parse("a0c10148-8865-4470-9631-8f36d79a41a5"), + RicohGattSpec.CameraState.STORAGE_INFO_CHARACTERISTIC_UUID, + ) + } + + @Test + fun `CameraState Operation Mode characteristic matches dm-zharov spec`() { + assertEquals( + Uuid.parse("1452335a-ec7f-4877-b8ab-0f72e18bb295"), + RicohGattSpec.CameraState.OPERATION_MODE_CHARACTERISTIC_UUID, + ) } @Test @@ -174,7 +241,6 @@ class RicohGattSpecTest { // Verify UUID format consistency - UUIDs should use uppercase hex digits val allUuids = listOf( - RicohGattSpec.SCAN_FILTER_SERVICE_UUID.toString(), RicohGattSpec.Firmware.SERVICE_UUID.toString(), RicohGattSpec.Firmware.VERSION_CHARACTERISTIC_UUID.toString(), RicohGattSpec.DeviceName.SERVICE_UUID.toString(), @@ -184,6 +250,18 @@ class RicohGattSpecTest { RicohGattSpec.DateTime.GEO_TAGGING_CHARACTERISTIC_UUID.toString(), RicohGattSpec.Location.SERVICE_UUID.toString(), RicohGattSpec.Location.LOCATION_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.Shooting.SERVICE_UUID.toString(), + RicohGattSpec.Shooting.SHOOTING_MODE_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.Shooting.CAPTURE_MODE_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.Shooting.DRIVE_MODE_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.Shooting.CAPTURE_STATUS_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.Shooting.OPERATION_REQUEST_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.WlanControl.SERVICE_UUID.toString(), + RicohGattSpec.WlanControl.NETWORK_TYPE_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.CameraState.CAMERA_POWER_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.CameraState.BATTERY_LEVEL_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.CameraState.STORAGE_INFO_CHARACTERISTIC_UUID.toString(), + RicohGattSpec.CameraState.OPERATION_MODE_CHARACTERISTIC_UUID.toString(), ) // All UUIDs should be properly formatted (8-4-4-4-12 format) diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolRemoteControlTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolRemoteControlTest.kt new file mode 100644 index 0000000..9aff2c5 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolRemoteControlTest.kt @@ -0,0 +1,412 @@ +package dev.sebastiano.camerasync.vendors.ricoh + +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Exhaustive tests for Ricoh BLE remote control protocol: capture status, drive mode, shooting + * mode. + */ +class RicohProtocolRemoteControlTest { + + // ==================== decodeCaptureStatus — length ==================== + + @Test + fun `decodeCaptureStatus returns Idle for empty array`() { + assertEquals(CaptureStatus.Idle, RicohProtocol.decodeCaptureStatus(byteArrayOf())) + } + + @Test + fun `decodeCaptureStatus returns Idle for single byte`() { + assertEquals(CaptureStatus.Idle, RicohProtocol.decodeCaptureStatus(byteArrayOf(0))) + assertEquals(CaptureStatus.Idle, RicohProtocol.decodeCaptureStatus(byteArrayOf(1))) + } + + @Test + fun `decodeCaptureStatus detects countdown`() { + val s = RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 1)) + assertTrue(s is CaptureStatus.Countdown) + assertEquals(-1, (s as CaptureStatus.Countdown).secondsRemaining) + } + + @Test + fun `decodeCaptureStatus countdown takes precedence over capturing`() { + val s = RicohProtocol.decodeCaptureStatus(byteArrayOf(1, 1)) + assertTrue(s is CaptureStatus.Countdown) + } + + @Test + fun `decodeCaptureStatus detects capturing`() { + assertEquals(CaptureStatus.Capturing, RicohProtocol.decodeCaptureStatus(byteArrayOf(1, 0))) + } + + @Test + fun `decodeCaptureStatus detects idle`() { + assertEquals(CaptureStatus.Idle, RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 0))) + } + + @Test + fun `decodeCaptureStatus uses only first two bytes when more present`() { + assertEquals(CaptureStatus.Idle, RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 0, 1, 1))) + assertEquals( + CaptureStatus.Countdown(-1), + RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 1, 0, 0)), + ) + } + + @Test + fun `decodeCaptureStatus capturing value 2 is still Capturing`() { + assertEquals(CaptureStatus.Capturing, RicohProtocol.decodeCaptureStatus(byteArrayOf(2, 0))) + } + + @Test + fun `decodeCaptureStatus countdown value 2 is still Countdown`() { + val s = RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 2)) + assertTrue(s is CaptureStatus.Countdown) + } + + @Test + fun `decodeCaptureStatus treats countdown byte as unsigned so 0x80 yields Countdown not Idle`() { + // Regression: bytes are signed; 0x80 is -128. Without "and 0xFF", countdown > 0 would be + // false. + val s = RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 0x80.toByte())) + assertTrue(s is CaptureStatus.Countdown) + } + + @Test + fun `decodeCaptureStatus treats capturing byte as unsigned so 0x80 yields Capturing not Idle`() { + // Regression: bytes are signed; 0x80 is -128. Without "and 0xFF", capturing > 0 would be + // false. + assertEquals( + CaptureStatus.Capturing, + RicohProtocol.decodeCaptureStatus(byteArrayOf(0x80.toByte(), 0)), + ) + } + + @Test + fun `decodeCaptureStatus treats high bytes 0xFF as non-zero`() { + assertTrue( + RicohProtocol.decodeCaptureStatus(byteArrayOf(0, 0xFF.toByte())) + is CaptureStatus.Countdown + ) + assertEquals( + CaptureStatus.Capturing, + RicohProtocol.decodeCaptureStatus(byteArrayOf(0xFF.toByte(), 0)), + ) + } + + // ==================== decodeDriveMode — all enum values ==================== + + @Test + fun `decodeDriveMode returns UNKNOWN for empty array`() { + assertEquals(DriveMode.UNKNOWN, RicohProtocol.decodeDriveMode(byteArrayOf())) + } + + @Test + fun `decodeDriveMode value 0 is SINGLE_SHOOTING`() { + assertEquals(DriveMode.SINGLE_SHOOTING, RicohProtocol.decodeDriveMode(byteArrayOf(0))) + } + + @Test + fun `decodeDriveMode value 1 is SELF_TIMER_10S`() { + assertEquals(DriveMode.SELF_TIMER_10S, RicohProtocol.decodeDriveMode(byteArrayOf(1))) + } + + @Test + fun `decodeDriveMode value 2 is SELF_TIMER_2S`() { + assertEquals(DriveMode.SELF_TIMER_2S, RicohProtocol.decodeDriveMode(byteArrayOf(2))) + } + + @Test + fun `decodeDriveMode value 3 is CONTINUOUS_SHOOTING`() { + assertEquals(DriveMode.CONTINUOUS_SHOOTING, RicohProtocol.decodeDriveMode(byteArrayOf(3))) + } + + @Test + fun `decodeDriveMode value 4 is BRACKET`() { + assertEquals(DriveMode.BRACKET, RicohProtocol.decodeDriveMode(byteArrayOf(4))) + } + + @Test + fun `decodeDriveMode values 7 to 9 are MULTI_EXPOSURE`() { + for (v in 7..9) { + assertEquals( + DriveMode.MULTI_EXPOSURE, + RicohProtocol.decodeDriveMode(byteArrayOf(v.toByte())), + ) + } + } + + @Test + fun `decodeDriveMode values 10 to 15 are INTERVAL`() { + for (v in 10..15) { + assertEquals(DriveMode.INTERVAL, RicohProtocol.decodeDriveMode(byteArrayOf(v.toByte()))) + } + } + + @Test + fun `decodeDriveMode value 16 maps to SINGLE_SHOOTING`() { + assertEquals(DriveMode.SINGLE_SHOOTING, RicohProtocol.decodeDriveMode(byteArrayOf(16))) + } + + @Test + fun `decodeDriveMode value 0xFF is UNKNOWN`() { + assertEquals(DriveMode.UNKNOWN, RicohProtocol.decodeDriveMode(byteArrayOf(0xFF.toByte()))) + } + + @Test + fun `decodeDriveMode value 33 is BRACKET`() { + assertEquals(DriveMode.BRACKET, RicohProtocol.decodeDriveMode(byteArrayOf(33))) + } + + @Test + fun `decodeDriveMode value 35 is MULTI_EXPOSURE`() { + assertEquals(DriveMode.MULTI_EXPOSURE, RicohProtocol.decodeDriveMode(byteArrayOf(35))) + } + + @Test + fun `decodeDriveMode value 37 is INTERVAL`() { + assertEquals(DriveMode.INTERVAL, RicohProtocol.decodeDriveMode(byteArrayOf(37))) + } + + @Test + fun `decodeDriveMode value 56 maps to SINGLE_SHOOTING`() { + assertEquals(DriveMode.SINGLE_SHOOTING, RicohProtocol.decodeDriveMode(byteArrayOf(56))) + } + + @Test + fun `decodeDriveMode uses only first byte`() { + assertEquals(DriveMode.SINGLE_SHOOTING, RicohProtocol.decodeDriveMode(byteArrayOf(0, 1, 2))) + } + + // ==================== decodeExposureMode (single-byte characteristic) ==================== + + @Test + fun `decodeExposureMode returns UNKNOWN for empty array`() { + assertEquals(ExposureMode.UNKNOWN, RicohProtocol.decodeExposureMode(byteArrayOf())) + } + + @Test + fun `decodeExposureMode 0 is PROGRAM_AUTO`() { + assertEquals(ExposureMode.PROGRAM_AUTO, RicohProtocol.decodeExposureMode(byteArrayOf(0))) + } + + @Test + fun `decodeExposureMode 1 is APERTURE_PRIORITY`() { + assertEquals( + ExposureMode.APERTURE_PRIORITY, + RicohProtocol.decodeExposureMode(byteArrayOf(1)), + ) + } + + @Test + fun `decodeExposureMode 3 is MANUAL`() { + assertEquals(ExposureMode.MANUAL, RicohProtocol.decodeExposureMode(byteArrayOf(3))) + } + + @Test + fun `decodeExposureMode 7 is SNAP_FOCUS_PROGRAM`() { + assertEquals( + ExposureMode.SNAP_FOCUS_PROGRAM, + RicohProtocol.decodeExposureMode(byteArrayOf(7)), + ) + } + + @Test + fun `decodeExposureMode 8 is UNKNOWN`() { + assertEquals(ExposureMode.UNKNOWN, RicohProtocol.decodeExposureMode(byteArrayOf(8))) + } + + @Test + fun `decodeExposureMode 0xFF is UNKNOWN`() { + assertEquals( + ExposureMode.UNKNOWN, + RicohProtocol.decodeExposureMode(byteArrayOf(0xFF.toByte())), + ) + } + + @Test + fun `decodeExposureMode uses only first byte`() { + assertEquals(ExposureMode.MANUAL, RicohProtocol.decodeExposureMode(byteArrayOf(3, 0, 0))) + } + + // ==================== decodeShootingMode — length ==================== + + @Test + fun `decodeShootingMode returns UNKNOWN pair for empty array`() { + val p = RicohProtocol.decodeShootingMode(byteArrayOf()) + assertEquals(CameraMode.UNKNOWN, p.first) + assertEquals(ExposureMode.UNKNOWN, p.second) + } + + @Test + fun `decodeShootingMode returns UNKNOWN pair for single byte`() { + val p = RicohProtocol.decodeShootingMode(byteArrayOf(0)) + assertEquals(CameraMode.UNKNOWN, p.first) + assertEquals(ExposureMode.UNKNOWN, p.second) + } + + @Test + fun `decodeShootingMode Still and P`() { + val (mode, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 0)) + assertEquals(CameraMode.STILL_IMAGE, mode) + assertEquals(ExposureMode.PROGRAM_AUTO, exp) + } + + @Test + fun `decodeShootingMode Movie and M`() { + val (mode, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(1, 3)) + assertEquals(CameraMode.MOVIE, mode) + assertEquals(ExposureMode.MANUAL, exp) + } + + @Test + fun `decodeShootingMode mode byte 0 is STILL_IMAGE`() { + val (mode, _) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 0)) + assertEquals(CameraMode.STILL_IMAGE, mode) + } + + @Test + fun `decodeShootingMode mode byte 1 is MOVIE`() { + val (mode, _) = RicohProtocol.decodeShootingMode(byteArrayOf(1, 0)) + assertEquals(CameraMode.MOVIE, mode) + } + + @Test + fun `decodeShootingMode mode byte 2 is UNKNOWN`() { + val (mode, _) = RicohProtocol.decodeShootingMode(byteArrayOf(2, 0)) + assertEquals(CameraMode.UNKNOWN, mode) + } + + @Test + fun `decodeShootingMode mode byte 0xFF is UNKNOWN`() { + val (mode, _) = RicohProtocol.decodeShootingMode(byteArrayOf(0xFF.toByte(), 0)) + assertEquals(CameraMode.UNKNOWN, mode) + } + + @Test + fun `decodeShootingMode exposure 0 is PROGRAM_AUTO`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 0)) + assertEquals(ExposureMode.PROGRAM_AUTO, exp) + } + + @Test + fun `decodeShootingMode exposure 1 is APERTURE_PRIORITY`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 1)) + assertEquals(ExposureMode.APERTURE_PRIORITY, exp) + } + + @Test + fun `decodeShootingMode exposure 2 is SHUTTER_PRIORITY`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 2)) + assertEquals(ExposureMode.SHUTTER_PRIORITY, exp) + } + + @Test + fun `decodeShootingMode exposure 3 is MANUAL`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 3)) + assertEquals(ExposureMode.MANUAL, exp) + } + + @Test + fun `decodeShootingMode exposure 4 is BULB`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 4)) + assertEquals(ExposureMode.BULB, exp) + } + + @Test + fun `decodeShootingMode exposure 5 is BULB_TIMER`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 5)) + assertEquals(ExposureMode.BULB_TIMER, exp) + } + + @Test + fun `decodeShootingMode exposure 6 is TIME`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 6)) + assertEquals(ExposureMode.TIME, exp) + } + + @Test + fun `decodeShootingMode exposure 7 is SNAP_FOCUS_PROGRAM`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 7)) + assertEquals(ExposureMode.SNAP_FOCUS_PROGRAM, exp) + } + + @Test + fun `decodeShootingMode exposure 8 is UNKNOWN`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 8)) + assertEquals(ExposureMode.UNKNOWN, exp) + } + + @Test + fun `decodeShootingMode exposure 0xFF is UNKNOWN`() { + val (_, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(0, 0xFF.toByte())) + assertEquals(ExposureMode.UNKNOWN, exp) + } + + @Test + fun `decodeShootingMode uses only first two bytes`() { + val (mode, exp) = RicohProtocol.decodeShootingMode(byteArrayOf(1, 3, 0, 0)) + assertEquals(CameraMode.MOVIE, mode) + assertEquals(ExposureMode.MANUAL, exp) + } + + // ==================== encodeOperationRequest ==================== + + @Test + fun `encodeOperationRequest Start AF yields 0x01 0x01`() { + assertArrayEquals( + byteArrayOf(0x01, 0x01), + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_START, + RicohProtocol.OP_REQ_PARAM_AF, + ), + ) + } + + @Test + fun `encodeOperationRequest Stop NoAF yields 0x02 0x00`() { + assertArrayEquals( + byteArrayOf(0x02, 0x00), + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_STOP, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + ) + } + + @Test + fun `encodeOperationRequest NOP GreenButton yields 0x00 0x02`() { + assertArrayEquals( + byteArrayOf(0x00, 0x02), + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_NOP, + RicohProtocol.OP_REQ_PARAM_GREEN_BUTTON, + ), + ) + } + + // ==================== encodeRemoteControlCommand (legacy) ==================== + + @Test + fun `encodeRemoteControlCommand RC_SHUTTER_PRESS yields 0x01`() { + assertArrayEquals( + byteArrayOf(0x01), + RicohProtocol.encodeRemoteControlCommand(RicohProtocol.RC_SHUTTER_PRESS), + ) + } + + @Test + fun `encodeRemoteControlCommand RC_SHUTTER_RELEASE yields 0x00`() { + assertArrayEquals( + byteArrayOf(0x00), + RicohProtocol.encodeRemoteControlCommand(RicohProtocol.RC_SHUTTER_RELEASE), + ) + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolTest.kt index 779b7e5..60abaff 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohProtocolTest.kt @@ -1,9 +1,16 @@ package dev.sebastiano.camerasync.vendors.ricoh +import dev.sebastiano.camerasync.domain.model.BatteryPosition +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode import dev.sebastiano.camerasync.domain.model.GpsLocation +import dev.sebastiano.camerasync.domain.model.PowerSource import java.time.ZoneId import java.time.ZonedDateTime import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -235,4 +242,171 @@ class RicohProtocolTest { RicohProtocol.decodeGeoTaggingEnabled(byteArrayOf()) } } + + // --- New Tests for Remote Control --- + + @Test + fun `decodeBatteryInfo decodes correct percentage`() { + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(85, 0)) + assertEquals(85, info.levelPercentage) + assertEquals(BatteryPosition.INTERNAL, info.position) + assertEquals(PowerSource.BATTERY, info.powerSource) + assertFalse(info.isCharging) + } + + @Test + fun `decodeBatteryInfo clamps percentage exceeding 100 to 100`() { + // Test value of 150 (0x96) + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(150.toByte(), 0)) + assertEquals(100, info.levelPercentage) + assertEquals(PowerSource.BATTERY, info.powerSource) + } + + @Test + fun `decodeBatteryInfo preserves valid boundary values 0 and 100`() { + // Test value of 0 + val infoZero = RicohProtocol.decodeBatteryInfo(byteArrayOf(0, 0)) + assertEquals(0, infoZero.levelPercentage) + assertEquals(PowerSource.BATTERY, infoZero.powerSource) + + // Test value of 100 + val infoHundred = RicohProtocol.decodeBatteryInfo(byteArrayOf(100, 0)) + assertEquals(100, infoHundred.levelPercentage) + assertEquals(PowerSource.BATTERY, infoHundred.powerSource) + } + + @Test + fun `decodeBatteryInfo clamps very large values to 100`() { + // Test value of 255 (0xFF) - maximum unsigned byte value + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(255.toByte(), 0)) + assertEquals(100, info.levelPercentage) + assertEquals(PowerSource.BATTERY, info.powerSource) + } + + @Test + fun `decodeBatteryInfo handles negative byte values without sign extension`() { + // Test that a negative byte (e.g. 0x80 = -128 as signed, 128 as unsigned) + // is correctly interpreted as unsigned and clamped to 100 + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(0x80.toByte(), 0)) + assertEquals(100, info.levelPercentage) + assertEquals(PowerSource.BATTERY, info.powerSource) + } + + @Test + fun `decodeBatteryInfo handles empty array`() { + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf()) + assertEquals(0, info.levelPercentage) + assertEquals(BatteryPosition.UNKNOWN, info.position) + assertEquals(PowerSource.UNKNOWN, info.powerSource) + } + + @Test + fun `decodeBatteryInfo detects AC adapter power source`() { + // Power source byte: 0 = Battery, 1 = AC Adapter + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(75, 1)) + assertEquals(75, info.levelPercentage) + assertEquals(PowerSource.AC_ADAPTER, info.powerSource) + assertEquals(BatteryPosition.INTERNAL, info.position) + } + + @Test + fun `decodeBatteryInfo handles unknown power source`() { + // Unknown power source value (2 in this case) + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(50, 2)) + assertEquals(50, info.levelPercentage) + assertEquals(PowerSource.UNKNOWN, info.powerSource) + assertEquals(BatteryPosition.INTERNAL, info.position) + } + + @Test + fun `decodeBatteryInfo handles missing power source byte`() { + // Only battery level, no power source byte + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(90)) + assertEquals(90, info.levelPercentage) + assertEquals(PowerSource.UNKNOWN, info.powerSource) + assertEquals(BatteryPosition.INTERNAL, info.position) + } + + @Test + fun `decodeBatteryInfo boundary value at 101 is clamped`() { + // Test the boundary just above 100 + val info = RicohProtocol.decodeBatteryInfo(byteArrayOf(101, 0)) + assertEquals(100, info.levelPercentage) + } + + @Test + fun `decodeStorageInfo decodes basic presence and remaining shots`() { + // Status 1 (present), Remaining 100 (0x64 00 00 00 LE) + val data = byteArrayOf(1, 0x64, 0, 0, 0) + val info = RicohProtocol.decodeStorageInfo(data) + + assertTrue(info.isPresent) + assertEquals(100, info.remainingShots) + assertFalse(info.isFull) + } + + @Test + fun `decodeStorageInfo detects full storage`() { + // Status 1 (present), Remaining 0 + val data = byteArrayOf(1, 0, 0, 0, 0) + val info = RicohProtocol.decodeStorageInfo(data) + + assertTrue(info.isPresent) + assertEquals(0, info.remainingShots) + assertTrue(info.isFull) + } + + @Test + fun `decodeCaptureStatus detects countdown`() { + // Capturing = 0, Countdown = 1 + val data = byteArrayOf(0, 1) + val status = RicohProtocol.decodeCaptureStatus(data) + assertTrue(status is CaptureStatus.Countdown) + } + + @Test + fun `decodeCaptureStatus detects capturing`() { + // Capturing = 1, Countdown = 0 + val data = byteArrayOf(1, 0) + val status = RicohProtocol.decodeCaptureStatus(data) + assertEquals(CaptureStatus.Capturing, status) + } + + @Test + fun `decodeCaptureStatus detects idle`() { + // Countdown = 0, Capturing = 0 + val data = byteArrayOf(0, 0) + val status = RicohProtocol.decodeCaptureStatus(data) + assertEquals(CaptureStatus.Idle, status) + } + + @Test + fun `decodeShootingMode decodes Still + P`() { + // Mode 0 (Still), Exposure 0 (P) + val data = byteArrayOf(0, 0) + val (mode, exposure) = RicohProtocol.decodeShootingMode(data) + assertEquals(CameraMode.STILL_IMAGE, mode) + assertEquals(ExposureMode.PROGRAM_AUTO, exposure) + } + + @Test + fun `decodeShootingMode decodes Movie + M`() { + // Mode 1 (Movie), Exposure 3 (M) + val data = byteArrayOf(1, 3) + val (mode, exposure) = RicohProtocol.decodeShootingMode(data) + assertEquals(CameraMode.MOVIE, mode) + assertEquals(ExposureMode.MANUAL, exposure) + } + + @Test + fun `decodeDriveMode decodes Single`() { + val mode = RicohProtocol.decodeDriveMode(byteArrayOf(0)) + assertEquals(DriveMode.SINGLE_SHOOTING, mode) + } + + @Test + fun `decodeDriveMode decodes SelfTimer`() { + val mode = RicohProtocol.decodeDriveMode(byteArrayOf(2)) + assertEquals(DriveMode.SELF_TIMER_2S, mode) + } } diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegateTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegateTest.kt new file mode 100644 index 0000000..421352f --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/ricoh/RicohRemoteControlDelegateTest.kt @@ -0,0 +1,323 @@ +package dev.sebastiano.camerasync.vendors.ricoh + +import com.juul.kable.Characteristic +import com.juul.kable.Peripheral +import com.juul.kable.WriteType +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.DriveMode +import dev.sebastiano.camerasync.domain.model.ExposureMode +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import dev.sebastiano.camerasync.testutils.WriteRecorder +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.uuid.ExperimentalUuidApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tests for [RicohRemoteControlDelegate] BLE flows using Mockk. */ +@OptIn(ExperimentalUuidApi::class) +class RicohRemoteControlDelegateTest { + + private fun createCamera(): Camera = + Camera( + identifier = "ricoh-ble-id", + name = "GR IIIx", + macAddress = "11:22:33:44:55:66", + vendor = RicohCameraVendor, + ) + + private fun createPeripheral( + observeFlow: kotlinx.coroutines.flow.Flow, + writeRecorder: WriteRecorder? = null, + ): Peripheral = + mockk(relaxed = true) { + every { observe(any(), any()) } returns observeFlow + if (writeRecorder != null) { + coEvery { write(any(), any(), any()) } answers + { + writeRecorder.record(firstArg(), secondArg(), thirdArg()) + } + } else { + coEvery { write(any(), any(), any()) } returns Unit + } + } + + private val operationRequestUuid = RicohGattSpec.Shooting.OPERATION_REQUEST_CHARACTERISTIC_UUID + + // ==================== triggerCapture ==================== + + @Test + fun `triggerCapture writes Operation Request Start then Stop with WithResponse`() = runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.triggerCapture() + + val data = recorder.dataFor(operationRequestUuid) + assertEquals(2, data.size) + assertArrayEquals( + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_START, + RicohProtocol.OP_REQ_PARAM_AF, + ), + data[0], + ) + assertArrayEquals( + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_STOP, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + data[1], + ) + val types = recorder.writeTypesFor(operationRequestUuid) + assertEquals(2, types.size) + assertTrue(types.all { it == WriteType.WithResponse }) + } + + // ==================== startBulbExposure / stopBulbExposure ==================== + + @Test + fun `startBulbExposure writes Operation Request Start NO_AF to operation request characteristic`() = + runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.startBulbExposure() + + val data = recorder.dataFor(operationRequestUuid) + assertEquals(1, data.size) + assertArrayEquals( + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_START, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + data[0], + ) + } + + @Test + fun `startBulbExposure uses NO_AF not AF to avoid focus hunt in dark bulb conditions`() = + runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.startBulbExposure() + + val data = recorder.dataFor(operationRequestUuid).single() + assertEquals( + "Parameter byte must be NO_AF (0), not AF (1); AF would hunt in dark and block exposure", + RicohProtocol.OP_REQ_PARAM_NO_AF.toByte(), + data[1], + ) + } + + @Test + fun `stopBulbExposure writes Operation Request Stop to operation request characteristic`() = + runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.stopBulbExposure() + + val data = recorder.dataFor(operationRequestUuid) + assertEquals(1, data.size) + assertArrayEquals( + RicohProtocol.encodeOperationRequest( + RicohProtocol.OP_REQ_STOP, + RicohProtocol.OP_REQ_PARAM_NO_AF, + ), + data[0], + ) + } + + @Test + fun `bulb and trigger use WithResponse`() = runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.startBulbExposure() + delegate.stopBulbExposure() + + val types = recorder.writeTypesFor(operationRequestUuid) + assertEquals(2, types.size) + assertTrue(types.all { it == WriteType.WithResponse }) + } + + // ==================== observe flows ==================== + + @Test + fun `observeBatteryLevel decodes first byte as level`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(75))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val info = delegate.observeBatteryLevel().first() + + assertEquals(75, info.levelPercentage) + } + + @Test + fun `observeCameraMode maps 0 to STILL_IMAGE`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(0))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeCameraMode().first() + + assertEquals(CameraMode.STILL_IMAGE, mode) + } + + @Test + fun `observeCameraMode maps 2 to MOVIE`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(2))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeCameraMode().first() + + assertEquals(CameraMode.MOVIE, mode) + } + + @Test + fun `observeCameraMode maps empty data to UNKNOWN`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf())) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeCameraMode().first() + + assertEquals(CameraMode.UNKNOWN, mode) + } + + @Test + fun `observeCameraMode maps unknown byte to UNKNOWN`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(0xFF.toByte()))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeCameraMode().first() + + assertEquals(CameraMode.UNKNOWN, mode) + } + + @Test + fun `observeCameraMode maps 1 to UNKNOWN`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(1))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeCameraMode().first() + + assertEquals(CameraMode.UNKNOWN, mode) + } + + @Test + fun `observeExposureMode decodes exposure from shooting mode characteristic`() = runTest { + // SHOOTING_MODE_CHARACTERISTIC: [ShootingMode, ExposureMode]. Byte 0=Still/Movie, byte + // 1=P/Av/Tv/M... + val peripheral = createPeripheral(flowOf(byteArrayOf(0, 1))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeExposureMode().first() + + assertEquals(ExposureMode.APERTURE_PRIORITY, mode) + } + + @Test + fun `observeExposureMode reads exposure from byte 1 not byte 0`() = runTest { + // Regression: byte 0=shooting mode (1=Movie), byte 1=exposure (0=P). Must decode byte 1. + val peripheral = createPeripheral(flowOf(byteArrayOf(1, 0))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeExposureMode().first() + + assertEquals(ExposureMode.PROGRAM_AUTO, mode) + } + + @Test + fun `observeExposureMode Movie and M decodes MANUAL from byte 1`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(1, 3))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeExposureMode().first() + + assertEquals(ExposureMode.MANUAL, mode) + } + + @Test + fun `observeExposureMode returns UNKNOWN for short or empty data`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(0))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeExposureMode().first() + + assertEquals(ExposureMode.UNKNOWN, mode) + } + + @Test + fun `observeDriveMode decodes drive byte`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(0))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeDriveMode().first() + + assertEquals(DriveMode.SINGLE_SHOOTING, mode) + } + + @Test + fun `observeDriveMode maps 3 to CONTINUOUS_SHOOTING`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(3))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeDriveMode().first() + + assertEquals(DriveMode.CONTINUOUS_SHOOTING, mode) + } + + @Test + fun `observeDriveMode uses distinctUntilChanged to avoid duplicate emissions`() = runTest { + // Verify that distinctUntilChanged prevents duplicate drive mode values from being emitted + val notifications = + flowOf( + byteArrayOf(3), // CONTINUOUS_SHOOTING + byteArrayOf(3), // Duplicate - should be filtered by distinctUntilChanged + byteArrayOf(2), // SELF_TIMER_2S + ) + val peripheral = createPeripheral(notifications) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val modes = delegate.observeDriveMode().take(2).toList() + + // Should only receive 2 distinct values, not 3 (duplicate filtered) + assertEquals(2, modes.size) + assertEquals(DriveMode.CONTINUOUS_SHOOTING, modes[0]) + assertEquals(DriveMode.SELF_TIMER_2S, modes[1]) + } + + @Test + fun `connectionMode is BLE_ONLY by default`() = runTest { + val peripheral = createPeripheral(flowOf(ByteArray(0))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + assertEquals(ShootingConnectionMode.BLE_ONLY, delegate.connectionMode.value) + } + + @Test + fun `observeCaptureStatus decodes capture status`() = runTest { + val peripheral = createPeripheral(flowOf(byteArrayOf(1, 0))) + val delegate = RicohRemoteControlDelegate(peripheral, camera = createCamera()) + + val status = delegate.observeCaptureStatus().first() + + assertEquals(CaptureStatus.Capturing, status) + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendorTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendorTest.kt index a9aac8d..98cb79e 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendorTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyCameraVendorTest.kt @@ -86,27 +86,27 @@ class SonyCameraVendorTest { @Test fun `capabilities indicate firmware version support`() { - assertTrue(SonyCameraVendor.getCapabilities().supportsFirmwareVersion) + assertTrue(SonyCameraVendor.getSyncCapabilities().supportsFirmwareVersion) } @Test fun `capabilities indicate no device name support`() { - assertFalse(SonyCameraVendor.getCapabilities().supportsDeviceName) + assertFalse(SonyCameraVendor.getSyncCapabilities().supportsDeviceName) } @Test fun `capabilities indicate date time sync support`() { - assertTrue(SonyCameraVendor.getCapabilities().supportsDateTimeSync) + assertTrue(SonyCameraVendor.getSyncCapabilities().supportsDateTimeSync) } @Test fun `capabilities indicate no geo tagging support`() { - assertFalse(SonyCameraVendor.getCapabilities().supportsGeoTagging) + assertFalse(SonyCameraVendor.getSyncCapabilities().supportsGeoTagging) } @Test fun `capabilities indicate location sync support`() { - assertTrue(SonyCameraVendor.getCapabilities().supportsLocationSync) + assertTrue(SonyCameraVendor.getSyncCapabilities().supportsLocationSync) } @Test diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyConnectionDelegateTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyConnectionDelegateTest.kt index 194eab3..6aab566 100644 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyConnectionDelegateTest.kt +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyConnectionDelegateTest.kt @@ -6,6 +6,7 @@ import com.juul.kable.DiscoveredService import com.juul.kable.Peripheral import dev.sebastiano.camerasync.domain.model.Camera import dev.sebastiano.camerasync.domain.model.GpsLocation +import dev.sebastiano.camerasync.testutils.WriteRecorder import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -431,28 +432,6 @@ class SonyConnectionDelegateTest { // ==================== Helpers ==================== - /** Records BLE writes for later assertion. Avoids MockK's `match` limitations. */ - private class WriteRecorder { - private val writes = mutableListOf>() - - fun record(char: Characteristic, data: ByteArray) { - writes.add(char.characteristicUuid to data.copyOf()) - } - - fun hasWriteTo(charUuid: Uuid): Boolean = writes.any { it.first == charUuid } - - fun hasWriteToWithData(charUuid: Uuid, data: ByteArray): Boolean = - writes.any { it.first == charUuid && it.second.contentEquals(data) } - - fun dataWrittenTo(charUuid: Uuid): ByteArray? = - writes.lastOrNull { it.first == charUuid }?.second - - fun countWritesWithData(charUuid: Uuid, data: ByteArray): Int = - writes.count { it.first == charUuid && it.second.contentEquals(data) } - - fun getAllWrites(): List> = writes.toList() - } - private fun createCamera(protocolVersion: Int? = null): Camera { val metadata = buildMap { if (protocolVersion != null) { diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolConfigTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolConfigTest.kt new file mode 100644 index 0000000..fb22b8d --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolConfigTest.kt @@ -0,0 +1,75 @@ +package dev.sebastiano.camerasync.vendors.sony + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tests for [SonyProtocol] configuration parsing, helper commands, and geo-tagging. */ +class SonyProtocolConfigTest { + + @Test + fun `parseConfigRequiresTimezone returns true when bit 1 is set`() { + val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x02, 0x00) + assertTrue(SonyProtocol.parseConfigRequiresTimezone(config)) + } + + @Test + fun `parseConfigRequiresTimezone returns true when bit 1 is set with other bits`() { + val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x06, 0x00) + assertTrue(SonyProtocol.parseConfigRequiresTimezone(config)) + } + + @Test + fun `parseConfigRequiresTimezone returns false when bit 1 is not set`() { + val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x04, 0x00) + assertFalse(SonyProtocol.parseConfigRequiresTimezone(config)) + } + + @Test + fun `parseConfigRequiresTimezone returns false when byte 4 is zero`() { + val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x00, 0x00) + assertFalse(SonyProtocol.parseConfigRequiresTimezone(config)) + } + + @Test + fun `parseConfigRequiresTimezone returns false for short data`() { + assertFalse(SonyProtocol.parseConfigRequiresTimezone(byteArrayOf(0x01, 0x02))) + } + + @Test + fun `createStatusNotifyEnable returns correct bytes`() { + val expected = byteArrayOf(0x03, 0x01, 0x02, 0x01) + assertArrayEquals(expected, SonyProtocol.createStatusNotifyEnable()) + } + + @Test + fun `createStatusNotifyDisable returns correct bytes`() { + val expected = byteArrayOf(0x03, 0x01, 0x02, 0x00) + assertArrayEquals(expected, SonyProtocol.createStatusNotifyDisable()) + } + + @Test + fun `createPairingInit returns correct bytes`() { + val expected = byteArrayOf(0x06, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00) + assertArrayEquals(expected, SonyProtocol.createPairingInit()) + } + + @Test + fun `getPairingInitData returns same as createPairingInit`() { + assertArrayEquals(SonyProtocol.createPairingInit(), SonyProtocol.getPairingInitData()) + } + + @Test + fun `encodeGeoTaggingEnabled returns empty array`() { + assertEquals(0, SonyProtocol.encodeGeoTaggingEnabled(true).size) + assertEquals(0, SonyProtocol.encodeGeoTaggingEnabled(false).size) + } + + @Test + fun `decodeGeoTaggingEnabled returns false`() { + assertFalse(SonyProtocol.decodeGeoTaggingEnabled(byteArrayOf(0x01))) + assertFalse(SonyProtocol.decodeGeoTaggingEnabled(byteArrayOf())) + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolDateTimeTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolDateTimeTest.kt new file mode 100644 index 0000000..3a7918a --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolDateTimeTest.kt @@ -0,0 +1,207 @@ +package dev.sebastiano.camerasync.vendors.sony + +import dev.sebastiano.camerasync.domain.model.GpsLocation +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [SonyProtocol] CC13 Time Area Setting encoding/decoding and date/time handling. + * + * See docs/sony/DATETIME_GPS_SYNC.md for protocol documentation. + */ +class SonyProtocolDateTimeTest { + + @Test + fun `encodeDateTime produces 13-byte CC13 packet`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals("CC13 packet should be exactly 13 bytes", 13, encoded.size) + } + + @Test + fun `encodeDateTime sets correct CC13 header`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) + val encoded = SonyProtocol.encodeDateTime(dateTime) + + assertEquals(0x0C.toByte(), encoded[0]) + assertEquals(0x00.toByte(), encoded[1]) + assertEquals(0x00.toByte(), encoded[2]) + } + + @Test + fun `encodeDateTime uses Big-Endian for year`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) + val encoded = SonyProtocol.encodeDateTime(dateTime) + + assertEquals(0x07.toByte(), encoded[3]) + assertEquals(0xE8.toByte(), encoded[4]) + + val buffer = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN) + buffer.position(3) + assertEquals(2024, buffer.short.toInt() and 0xFFFF) + } + + @Test + fun `encodeDateTime sets correct date components`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) + val encoded = SonyProtocol.encodeDateTime(dateTime) + + assertEquals(12, encoded[5].toInt() and 0xFF) + assertEquals(25, encoded[6].toInt() and 0xFF) + assertEquals(14, encoded[7].toInt() and 0xFF) + assertEquals(30, encoded[8].toInt() and 0xFF) + assertEquals(45, encoded[9].toInt() and 0xFF) + } + + @Test + fun `encodeDateTime sets DST flag to 0 for standard time`() { + val dateTime = ZonedDateTime.of(2024, 1, 15, 12, 0, 0, 0, ZoneOffset.UTC) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals(0x00.toByte(), encoded[10]) + } + + @Test + fun `encodeDateTime sets DST flag to 1 during daylight saving time`() { + val dstZone = ZoneId.of("America/New_York") + val dateTime = ZonedDateTime.of(2024, 7, 15, 12, 0, 0, 0, dstZone) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals(0x01.toByte(), encoded[10]) + } + + @Test + fun `encodeDateTime uses split format for timezone offset`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals(8.toByte(), encoded[11]) + assertEquals(0.toByte(), encoded[12]) + } + + @Test + fun `encodeDateTime handles negative timezone offset`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(-5)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals((-5).toByte(), encoded[11]) + assertEquals(0.toByte(), encoded[12]) + } + + @Test + fun `encodeDateTime handles UTC timezone`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals(0.toByte(), encoded[11]) + assertEquals(0.toByte(), encoded[12]) + } + + @Test + fun `encodeDateTime handles fractional timezone offset`() { + val dateTime = + ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHoursMinutes(5, 30)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + assertEquals(5.toByte(), encoded[11]) + assertEquals(30.toByte(), encoded[12]) + } + + @Test + fun `encodeDateTime preserves local time not UTC`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + + val buffer = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN) + buffer.position(3) + assertEquals(2024, buffer.short.toInt() and 0xFFFF) + assertEquals(12, buffer.get().toInt() and 0xFF) + assertEquals(25, buffer.get().toInt() and 0xFF) + assertEquals(14, buffer.get().toInt() and 0xFF) + assertEquals(30, buffer.get().toInt() and 0xFF) + assertEquals(45, buffer.get().toInt() and 0xFF) + } + + @Test + fun `decodeDateTime handles CC13 format`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + val decoded = SonyProtocol.decodeDateTime(encoded) + + assertTrue("Should contain year", decoded.contains("2024")) + assertTrue("Should contain month-day", decoded.contains("12-25")) + assertTrue("Should contain time", decoded.contains("14:30:45")) + } + + @Test + fun `decodeDateTime handles DD11 format`() { + val location = + GpsLocation( + latitude = 37.7749, + longitude = -122.4194, + altitude = 0.0, + timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), + ) + val encoded = SonyProtocol.encodeLocation(location) + val decoded = SonyProtocol.decodeDateTime(encoded) + + assertTrue("Should contain date", decoded.contains("2024-12-25")) + assertTrue("Should contain time", decoded.contains("14:30:45")) + } + + @Test + fun `decodeDateTime returns error for invalid size`() { + val decoded = SonyProtocol.decodeDateTime(ByteArray(10)) + assertTrue("Should indicate invalid data", decoded.contains("Invalid data")) + } + + @Test + fun `decodeDateTime shows DST indicator when set`() { + val dstZone = ZoneId.of("America/New_York") + val dateTime = ZonedDateTime.of(2024, 7, 15, 12, 0, 0, 0, dstZone) + val encoded = SonyProtocol.encodeDateTime(dateTime) + val decoded = SonyProtocol.decodeDateTime(encoded) + assertTrue("Should indicate DST", decoded.contains("DST")) + } + + @Test + fun `encodeDateTime handles fractional timezone offset near zero`() { + val dateTime = + ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHoursMinutes(0, -30)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + val decoded = SonyProtocol.decodeDateTime(encoded) + assertTrue( + "Decoded string should contain UTC+00:30 due to Sony's bug, but was: $decoded", + decoded.contains("+00:30"), + ) + } + + @Test + fun `CC13 encode-decode round trip preserves data`() { + val dateTime = ZonedDateTime.of(2024, 6, 15, 10, 25, 30, 0, ZoneOffset.ofHours(-5)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + + val buffer = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN) + buffer.position(3) + assertEquals(2024, buffer.short.toInt() and 0xFFFF) + assertEquals(6, buffer.get().toInt() and 0xFF) + assertEquals(15, buffer.get().toInt() and 0xFF) + assertEquals(10, buffer.get().toInt() and 0xFF) + assertEquals(25, buffer.get().toInt() and 0xFF) + assertEquals(30, buffer.get().toInt() and 0xFF) + buffer.get() + assertEquals(-5, buffer.get().toInt()) + assertEquals(0, buffer.get().toInt() and 0xFF) + } + + @Test + fun `verify CC13 uses Big-Endian - explicit byte check`() { + val dateTime = ZonedDateTime.of(2025, 1, 1, 12, 0, 0, 0, ZoneOffset.ofHours(9)) + val encoded = SonyProtocol.encodeDateTime(dateTime) + + assertEquals("Year high byte", 0x07.toByte(), encoded[3]) + assertEquals("Year low byte", 0xE9.toByte(), encoded[4]) + assertEquals("TZ hours", 9.toByte(), encoded[11]) + assertEquals("TZ minutes", 0.toByte(), encoded[12]) + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolLocationTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolLocationTest.kt new file mode 100644 index 0000000..49b4125 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolLocationTest.kt @@ -0,0 +1,321 @@ +package dev.sebastiano.camerasync.vendors.sony + +import dev.sebastiano.camerasync.domain.model.GpsLocation +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.time.ZoneOffset +import java.time.ZonedDateTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [SonyProtocol] DD11 Location packet encoding/decoding and GPS sync. + * + * See docs/sony/DATETIME_GPS_SYNC.md for protocol documentation. + */ +class SonyProtocolLocationTest { + + @Test + fun `encodeLocation produces 95 bytes with timezone`() { + val location = + GpsLocation( + latitude = 37.7749, + longitude = -122.4194, + altitude = 10.0, + timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), + ) + val encoded = SonyProtocol.encodeLocation(location) + assertEquals(95, encoded.size) + } + + @Test + fun `encodeLocationPacket without timezone produces 91 bytes`() { + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = false, + ) + assertEquals(91, packet.size) + } + + @Test + fun `encodeLocationPacket uses Big-Endian for payload length`() { + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = true, + ) + assertEquals(0x00.toByte(), packet[0]) + assertEquals(0x5D.toByte(), packet[1]) + val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + assertEquals(93, buffer.short.toInt()) + } + + @Test + fun `encodeLocationPacket sets correct fixed header`() { + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = true, + ) + assertEquals(0x08.toByte(), packet[2]) + assertEquals(0x02.toByte(), packet[3]) + assertEquals(0xFC.toByte(), packet[4]) + } + + @Test + fun `encodeLocationPacket sets timezone flag correctly`() { + val withTz = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = true, + ) + assertEquals(0x03.toByte(), withTz[5]) + + val withoutTz = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = false, + ) + assertEquals(0x00.toByte(), withoutTz[5]) + } + + @Test + fun `encodeLocationPacket sets correct padding bytes`() { + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = true, + ) + assertEquals(0x00.toByte(), packet[6]) + assertEquals(0x00.toByte(), packet[7]) + assertEquals(0x10.toByte(), packet[8]) + assertEquals(0x10.toByte(), packet[9]) + assertEquals(0x10.toByte(), packet[10]) + } + + @Test + fun `encodeLocationPacket uses Big-Endian for coordinates`() { + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 37.7749, + longitude = -122.4194, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = true, + ) + + val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + buffer.position(11) + val expectedLat = (37.7749 * 10_000_000).toInt() + val expectedLon = (-122.4194 * 10_000_000).toInt() + assertEquals(expectedLat, buffer.int) + assertEquals(expectedLon, buffer.int) + } + + @Test + fun `encodeLocationPacket handles negative latitude correctly`() { + val packet = + SonyProtocol.encodeLocationPacket( + latitude = -33.8688, + longitude = 151.2093, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = true, + ) + val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + buffer.position(11) + val expectedLat = (-33.8688 * 10_000_000).toInt() + val expectedLon = (151.2093 * 10_000_000).toInt() + assertEquals(expectedLat, buffer.int) + assertEquals(expectedLon, buffer.int) + } + + @Test + fun `encodeLocationPacket uses Big-Endian for year`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = dateTime, + includeTimezone = true, + ) + val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + buffer.position(19) + assertEquals(2024, buffer.short.toInt() and 0xFFFF) + } + + @Test + fun `encodeLocationPacket converts to UTC`() { + val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = dateTime, + includeTimezone = true, + ) + val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + buffer.position(19) + assertEquals(2024, buffer.short.toInt() and 0xFFFF) + assertEquals(12, buffer.get().toInt() and 0xFF) + assertEquals(25, buffer.get().toInt() and 0xFF) + assertEquals(6, buffer.get().toInt() and 0xFF) + assertEquals(30, buffer.get().toInt() and 0xFF) + assertEquals(45, buffer.get().toInt() and 0xFF) + } + + @Test + fun `encodeLocationPacket uses system timezone for offset`() { + val dateTime = ZonedDateTime.now(ZoneOffset.UTC) + val packet = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 0.0, + dateTime = dateTime, + includeTimezone = true, + ) + val systemZone = java.time.ZoneId.systemDefault() + val now = java.time.Instant.now() + val zoneRules = systemZone.rules + val standardOffset = zoneRules.getStandardOffset(now) + val actualOffset = zoneRules.getOffset(now) + val expectedTzMinutes = standardOffset.totalSeconds / 60 + val expectedDstMinutes = (actualOffset.totalSeconds - standardOffset.totalSeconds) / 60 + val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) + buffer.position(91) + assertEquals(expectedTzMinutes.toShort(), buffer.short) + assertEquals(expectedDstMinutes.toShort(), buffer.short) + } + + @Test + fun `encodeLocationPacket handles extreme coordinates`() { + val northPole = + SonyProtocol.encodeLocationPacket( + latitude = 90.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = false, + ) + var buffer = ByteBuffer.wrap(northPole).order(ByteOrder.BIG_ENDIAN) + buffer.position(11) + assertEquals(900_000_000, buffer.int) + + val southPole = + SonyProtocol.encodeLocationPacket( + latitude = -90.0, + longitude = 0.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = false, + ) + buffer = ByteBuffer.wrap(southPole).order(ByteOrder.BIG_ENDIAN) + buffer.position(11) + assertEquals(-900_000_000, buffer.int) + + val dateLine = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = 180.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = false, + ) + buffer = ByteBuffer.wrap(dateLine).order(ByteOrder.BIG_ENDIAN) + buffer.position(15) + assertEquals(1_800_000_000, buffer.int) + + val negDateLine = + SonyProtocol.encodeLocationPacket( + latitude = 0.0, + longitude = -180.0, + dateTime = ZonedDateTime.now(ZoneOffset.UTC), + includeTimezone = false, + ) + buffer = ByteBuffer.wrap(negDateLine).order(ByteOrder.BIG_ENDIAN) + buffer.position(15) + assertEquals(-1_800_000_000, buffer.int) + } + + @Test + fun `decodeLocation correctly decodes encoded location`() { + val location = + GpsLocation( + latitude = 37.7749, + longitude = -122.4194, + altitude = 0.0, + timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), + ) + val encoded = SonyProtocol.encodeLocation(location) + val decoded = SonyProtocol.decodeLocation(encoded) + assertTrue(decoded.contains("37.7749")) + assertTrue(decoded.contains("-122.4194")) + assertTrue(decoded.contains("2024-12-25")) + } + + @Test + fun `decodeLocation handles negative coordinates`() { + val location = + GpsLocation( + latitude = -33.8688, + longitude = 151.2093, + altitude = 0.0, + timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), + ) + val encoded = SonyProtocol.encodeLocation(location) + val decoded = SonyProtocol.decodeLocation(encoded) + assertTrue(decoded.contains("-33.8688")) + assertTrue(decoded.contains("151.2093")) + } + + @Test + fun `decodeLocation returns error for short data`() { + val decoded = SonyProtocol.decodeLocation(ByteArray(10)) + assertTrue(decoded.contains("Invalid data")) + } + + @Test + fun `DD11 encode-decode round trip preserves coordinates`() { + val location = + GpsLocation( + latitude = 51.5074, + longitude = -0.1278, + altitude = 0.0, + timestamp = ZonedDateTime.of(2024, 3, 20, 15, 45, 0, 0, ZoneOffset.UTC), + ) + val encoded = SonyProtocol.encodeLocation(location) + val decoded = SonyProtocol.decodeLocation(encoded) + assertTrue(decoded.contains("51.507")) + assertTrue(decoded.contains("-0.127")) + assertTrue(decoded.contains("2024-03-20")) + assertTrue(decoded.contains("15:45:00")) + } + + @Test + fun `verify Big-Endian is used throughout - explicit byte check`() { + val dateTime = ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC) + val location = + GpsLocation(latitude = 1.0, longitude = 2.0, altitude = 0.0, timestamp = dateTime) + val encoded = SonyProtocol.encodeLocation(location) + assertEquals(0x07.toByte(), encoded[19]) + assertEquals(0xE9.toByte(), encoded[20]) + assertEquals(0x00.toByte(), encoded[11]) + assertEquals(0x98.toByte(), encoded[12]) + assertEquals(0x96.toByte(), encoded[13]) + assertEquals(0x80.toByte(), encoded[14]) + assertEquals(0x01.toByte(), encoded[15]) + assertEquals(0x31.toByte(), encoded[16]) + assertEquals(0x2D.toByte(), encoded[17]) + assertEquals(0x00.toByte(), encoded[18]) + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolRemoteControlTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolRemoteControlTest.kt new file mode 100644 index 0000000..bcdd8fe --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolRemoteControlTest.kt @@ -0,0 +1,681 @@ +package dev.sebastiano.camerasync.vendors.sony + +import dev.sebastiano.camerasync.domain.model.BatteryPosition +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.FocusStatus +import dev.sebastiano.camerasync.domain.model.PowerSource +import dev.sebastiano.camerasync.domain.model.RecordingStatus +import dev.sebastiano.camerasync.domain.model.ShutterStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Exhaustive tests for Sony BLE remote control: FF02 notifications, CC09 TLV, and FF01 commands. + */ +class SonyProtocolRemoteControlTest { + + // ==================== FF02 parseFf02Notification — length & prefix ==================== + + @Test + fun `parseFf02Notification returns null for empty array`() { + assertNull(SonyProtocol.parseFf02Notification(byteArrayOf())) + } + + @Test + fun `parseFf02Notification returns null for single byte`() { + assertNull(SonyProtocol.parseFf02Notification(byteArrayOf(0x02.toByte()))) + } + + @Test + fun `parseFf02Notification returns null for two bytes`() { + assertNull(SonyProtocol.parseFf02Notification(byteArrayOf(0x02.toByte(), 0x3F.toByte()))) + } + + @Test + fun `parseFf02Notification returns null when first byte is 0x00`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x00.toByte(), 0x3F.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null when first byte is 0x01`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x01.toByte(), 0x3F.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null when first byte is 0x03`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x03.toByte(), 0x3F.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification accepts payload with extra bytes after third`() { + val notif = + SonyProtocol.parseFf02Notification( + byteArrayOf( + 0x02.toByte(), + 0x3F.toByte(), + 0x20.toByte(), + 0x99.toByte(), + 0x99.toByte(), + ) + ) + assertTrue(notif is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOCKED, (notif as SonyProtocol.Ff02Notification.Focus).status) + } + + // ==================== FF02 Focus (0x3F) — every value ==================== + + @Test + fun `parseFf02Notification Focus value 0x00 is LOST`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3F.toByte(), 0x00.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOST, (n as SonyProtocol.Ff02Notification.Focus).status) + } + + @Test + fun `parseFf02Notification Focus value 0x01 is LOST`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3F.toByte(), 0x01.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOST, (n as SonyProtocol.Ff02Notification.Focus).status) + } + + @Test + fun `parseFf02Notification Focus value 0x1F is LOST`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3F.toByte(), 0x1F.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOST, (n as SonyProtocol.Ff02Notification.Focus).status) + } + + @Test + fun `parseFf02Notification Focus value 0x20 is LOCKED`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3F.toByte(), 0x20.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOCKED, (n as SonyProtocol.Ff02Notification.Focus).status) + } + + @Test + fun `parseFf02Notification Focus value 0x21 is LOST`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3F.toByte(), 0x21.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOST, (n as SonyProtocol.Ff02Notification.Focus).status) + } + + @Test + fun `parseFf02Notification Focus value 0xFF is LOST`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3F.toByte(), 0xFF.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Focus) + assertEquals(FocusStatus.LOST, (n as SonyProtocol.Ff02Notification.Focus).status) + } + + // ==================== FF02 Shutter (0xA0) — every value ==================== + + @Test + fun `parseFf02Notification Shutter value 0x00 is READY`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xA0.toByte(), 0x00.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Shutter) + assertEquals(ShutterStatus.READY, (n as SonyProtocol.Ff02Notification.Shutter).status) + } + + @Test + fun `parseFf02Notification Shutter value 0x20 is ACTIVE`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xA0.toByte(), 0x20.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Shutter) + assertEquals(ShutterStatus.ACTIVE, (n as SonyProtocol.Ff02Notification.Shutter).status) + } + + @Test + fun `parseFf02Notification Shutter value 0x1F is READY`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xA0.toByte(), 0x1F.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Shutter) + assertEquals(ShutterStatus.READY, (n as SonyProtocol.Ff02Notification.Shutter).status) + } + + @Test + fun `parseFf02Notification Shutter value 0x21 is READY`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xA0.toByte(), 0x21.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Shutter) + assertEquals(ShutterStatus.READY, (n as SonyProtocol.Ff02Notification.Shutter).status) + } + + @Test + fun `parseFf02Notification Shutter value 0xFF is READY`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xA0.toByte(), 0xFF.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Shutter) + assertEquals(ShutterStatus.READY, (n as SonyProtocol.Ff02Notification.Shutter).status) + } + + // ==================== FF02 Recording (0xD5) — every value ==================== + + @Test + fun `parseFf02Notification Recording value 0x00 is IDLE`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD5.toByte(), 0x00.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Recording) + assertEquals(RecordingStatus.IDLE, (n as SonyProtocol.Ff02Notification.Recording).status) + } + + @Test + fun `parseFf02Notification Recording value 0x20 is RECORDING`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD5.toByte(), 0x20.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Recording) + assertEquals( + RecordingStatus.RECORDING, + (n as SonyProtocol.Ff02Notification.Recording).status, + ) + } + + @Test + fun `parseFf02Notification Recording value 0x1F is IDLE`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD5.toByte(), 0x1F.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Recording) + assertEquals(RecordingStatus.IDLE, (n as SonyProtocol.Ff02Notification.Recording).status) + } + + @Test + fun `parseFf02Notification Recording value 0x21 is IDLE`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD5.toByte(), 0x21.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Recording) + assertEquals(RecordingStatus.IDLE, (n as SonyProtocol.Ff02Notification.Recording).status) + } + + @Test + fun `parseFf02Notification Recording value 0xFF is IDLE`() { + val n = + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD5.toByte(), 0xFF.toByte()) + ) + assertTrue(n is SonyProtocol.Ff02Notification.Recording) + assertEquals(RecordingStatus.IDLE, (n as SonyProtocol.Ff02Notification.Recording).status) + } + + // ==================== FF02 unknown type bytes ==================== + + @Test + fun `parseFf02Notification returns null for type 0x00`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x00.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0x3E`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x3E.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0x40`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x40.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0x9F`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0x9F.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0xA1`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xA1.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0xD4`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD4.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0xD6`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xD6.toByte(), 0x20.toByte()) + ) + ) + } + + @Test + fun `parseFf02Notification returns null for type 0xFF`() { + assertNull( + SonyProtocol.parseFf02Notification( + byteArrayOf(0x02.toByte(), 0xFF.toByte(), 0x00.toByte()) + ) + ) + } + + // ==================== CC09 decodeCameraStatus — length & structure ==================== + + @Test + fun `decodeCameraStatus returns UNKNOWN for empty array`() { + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(byteArrayOf())) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN for 1 byte`() { + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(byteArrayOf(0x00))) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN for 2 bytes`() { + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(byteArrayOf(0x00, 0x08))) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN for 3 bytes`() { + assertEquals( + CameraMode.UNKNOWN, + SonyProtocol.decodeCameraStatus(byteArrayOf(0x00, 0x08, 0x00)), + ) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN for 4 bytes only header`() { + assertEquals( + CameraMode.UNKNOWN, + SonyProtocol.decodeCameraStatus(byteArrayOf(0x00, 0x08, 0x00, 0x01)), + ) + } + + @Test + fun `decodeCameraStatus returns MOVIE when tag 0x0008 value is 1`() { + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x01) + assertEquals(CameraMode.MOVIE, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus value 0 returns UNKNOWN - tag 0x0008 is recording state not mode`() { + // Regression: tag 0x0008 = Movie Recording (1=Recording, 0=Not Recording). Value 0 means + // not recording; camera could be in still mode OR in movie mode but idle. Conflating 0 with + // STILL_IMAGE misreports movie-mode-idle as still. Must return UNKNOWN. + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x00) + assertNotEquals(CameraMode.STILL_IMAGE, SonyProtocol.decodeCameraStatus(bytes)) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN when tag 0x0008 value is 2`() { + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x02) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN when tag 0x0008 value is 0xFF`() { + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x01, 0xFF.toByte()) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN when tag 0x0008 has length 0`() { + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x00) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN when first tag is 0x0001 not 0x0008`() { + val bytes = byteArrayOf(0x00, 0x01, 0x00, 0x01, 0x00) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns MOVIE when tag 0x0008 is second TLV`() { + // First TLV: tag 0x0001 length 1 value 0; Second: tag 0x0008 length 1 value 1 + val bytes = byteArrayOf(0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01) + assertEquals(CameraMode.MOVIE, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN when tag 0x0008 is second TLV value 0`() { + val bytes = byteArrayOf(0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00, 0x01, 0x00) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus uses only first value byte when length is 2`() { + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x02, 0x01, 0x00) + assertEquals(CameraMode.MOVIE, SonyProtocol.decodeCameraStatus(bytes)) + } + + @Test + fun `decodeCameraStatus returns UNKNOWN when TLV length would exceed buffer`() { + val bytes = byteArrayOf(0x00, 0x08, 0x00, 0x05, 0x01) + assertEquals(CameraMode.UNKNOWN, SonyProtocol.decodeCameraStatus(bytes)) + } + + // ==================== decodeBatteryInfo ==================== + + @Test + fun `decodeBatteryInfo decodes correct percentage and position`() { + val data = byteArrayOf(1, 1, 1, 0, 0, 0, 0, 85, 0) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(85, info.levelPercentage) + assertEquals(BatteryPosition.INTERNAL, info.position) + assertEquals(PowerSource.BATTERY, info.powerSource) + assertFalse(info.isCharging) + } + + @Test + fun `decodeBatteryInfo detects USB power`() { + val data = byteArrayOf(1, 1, 1, 0, 0, 0, 0, 50, 3) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(50, info.levelPercentage) + assertEquals(PowerSource.USB, info.powerSource) + assertTrue(info.isCharging) + } + + @Test + fun `decodeBatteryInfo position byte 0x02 yields GRIP_1`() { + val data = byteArrayOf(1, 1, 0x02, 0, 0, 0, 0, 50, 0) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(BatteryPosition.GRIP_1, info.position) + } + + @Test + fun `decodeBatteryInfo position byte 0x03 yields GRIP_2`() { + val data = byteArrayOf(1, 1, 0x03, 0, 0, 0, 0, 50, 0) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(BatteryPosition.GRIP_2, info.position) + } + + @Test + fun `decodeBatteryInfo position byte with high bit set yields UNKNOWN (sign extension regression)`() { + val data = byteArrayOf(1, 1, 0x80.toByte(), 0, 0, 0, 0, 50, 0) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(BatteryPosition.UNKNOWN, info.position) + } + + @Test + fun `decodeBatteryInfo power source byte with high bit set yields BATTERY (sign extension regression)`() { + val data = byteArrayOf(1, 1, 1, 0, 0, 0, 0, 50, 0x80.toByte()) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(PowerSource.BATTERY, info.powerSource) + } + + @Test + fun `decodeBatteryInfo power source 0x03 as byte yields USB (unsigned match regression)`() { + val data = byteArrayOf(1, 1, 1, 0, 0, 0, 0, 50, 0x03) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(PowerSource.USB, info.powerSource) + } + + @Test + fun `decodeBatteryInfo two packs uses power source at index 16 not 8`() { + // 17 bytes: pack1 (0-7), pack2 (8-15), power at 16. Byte at 8 is second pack's Enable. + // Put 0x03 at index 8 (would be misread as USB if offset were wrong) and 0 at index 16. + val data = + byteArrayOf( + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 50, // pack 1: internal, 50% + 0x03, + 1, + 0x02, + 0, + 0, + 0, + 0, + 75, // pack 2: first byte 0x03 (Enable), grip, 75% + 0, // power source = BATTERY + ) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(50, info.levelPercentage) + assertEquals(BatteryPosition.INTERNAL, info.position) + assertEquals(PowerSource.BATTERY, info.powerSource) + assertFalse(info.isCharging) + } + + @Test + fun `decodeBatteryInfo two packs with USB power at index 16`() { + val data = + byteArrayOf( + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 50, // pack 1 + 1, + 1, + 0x02, + 0, + 0, + 0, + 0, + 75, // pack 2 + 0x03, // power source = USB + ) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(PowerSource.USB, info.powerSource) + assertTrue(info.isCharging) + } + + @Test + fun `decodeBatteryInfo single pack 8 bytes has no power source`() { + val data = byteArrayOf(1, 1, 1, 0, 0, 0, 0, 85) // exactly one pack, no power byte + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(85, info.levelPercentage) + assertEquals(PowerSource.UNKNOWN, info.powerSource) + assertFalse(info.isCharging) + } + + @Test + fun `decodeBatteryInfo clamps negative percentage to 0`() { + // Create a byte array where the 4-byte big-endian int at offset 4 is negative + // Using 0xFF, 0xFF, 0xFF, 0xFF = -1 as a signed int + val data = + byteArrayOf(1, 1, 1, 0, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0) + val info = SonyProtocol.decodeBatteryInfo(data) + assertEquals(0, info.levelPercentage) + } + + @Test + fun `decodeBatteryInfo clamps percentage exceeding 100 to 100`() { + // Create a byte array where the 4-byte big-endian int at offset 4 is > 100 + // Using 0x00, 0x00, 0x00, 0x65 = 101 in decimal + // Big-endian: bytes at offset 4-7 form the int value + val data = byteArrayOf(1, 1, 1, 0, 0x00, 0x00, 0x00, 0x65, 0) + val info = SonyProtocol.decodeBatteryInfo(data) + // 0x00000065 = 101, which should be clamped to 100 + assertEquals(100, info.levelPercentage) + } + + @Test + fun `decodeBatteryInfo preserves valid boundary values 0 and 100`() { + // Test that 0 is not clamped + val dataZero = byteArrayOf(1, 1, 1, 0, 0x00, 0x00, 0x00, 0x00, 0) + val infoZero = SonyProtocol.decodeBatteryInfo(dataZero) + assertEquals(0, infoZero.levelPercentage) + + // Test that 100 is not clamped + val dataHundred = byteArrayOf(1, 1, 1, 0, 0x00, 0x00, 0x00, 0x64, 0) + val infoHundred = SonyProtocol.decodeBatteryInfo(dataHundred) + assertEquals(100, infoHundred.levelPercentage) + } + + @Test + fun `decodeBatteryInfo clamps very large values to 100`() { + // Test a very large value (255) is clamped to 100 + val data = byteArrayOf(1, 1, 1, 0, 0x00, 0x00, 0x00, 0xFF.toByte(), 0) + val info = SonyProtocol.decodeBatteryInfo(data) + // 0x000000FF = 255, which should be clamped to 100 + assertEquals(100, info.levelPercentage) + } + + // ==================== decodeStorageInfo ==================== + + @Test + fun `decodeStorageInfo decodes presence and shots`() { + val data = byteArrayOf(1, 0, 0, 0, 0x64, 0, 0, 0, 0) + val info = SonyProtocol.decodeStorageInfo(data) + assertTrue(info.isPresent) + assertEquals(100, info.remainingShots) + assertFalse(info.isFull) + } + + @Test + fun `decodeStorageInfo status 0 reports no media`() { + val data = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0) + val info = SonyProtocol.decodeStorageInfo(data) + assertFalse(info.isPresent) + assertEquals(0, info.remainingShots) + assertTrue(info.isFull) + } + + @Test + fun `decodeStorageInfo status 0x80 reports media present unsigned byte regression`() { + val data = byteArrayOf(0x80.toByte(), 0, 0, 0, 50, 0, 0, 0, 0) + val info = SonyProtocol.decodeStorageInfo(data) + assertTrue(info.isPresent) + assertEquals(50, info.remainingShots) + } + + @Test + fun `decodeStorageInfo status 0xFF reports media present unsigned byte regression`() { + val data = byteArrayOf(0xFF.toByte(), 0, 0, 0, 10, 0, 0, 0, 0) + val info = SonyProtocol.decodeStorageInfo(data) + assertTrue(info.isPresent) + assertEquals(10, info.remainingShots) + } + + // ==================== encodeRemoteControlCommand ==================== + + @Test + fun `encodeRemoteControlCommand two-byte form for RC_SHUTTER_HALF_PRESS`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_PRESS) + assertEquals(2, b.size) + assertEquals(0x01, b[0].toInt() and 0xFF) + assertEquals(SonyProtocol.RC_SHUTTER_HALF_PRESS, b[1].toInt() and 0xFF) + } + + @Test + fun `encodeRemoteControlCommand two-byte form for RC_SHUTTER_FULL_RELEASE`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_RELEASE) + assertEquals(2, b.size) + assertEquals(0x01, b[0].toInt() and 0xFF) + assertEquals(SonyProtocol.RC_SHUTTER_FULL_RELEASE, b[1].toInt() and 0xFF) + } + + @Test + fun `encodeRemoteControlCommand two-byte form for RC_VIDEO_REC`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_VIDEO_REC) + assertEquals(2, b.size) + assertEquals(0x01, b[0].toInt() and 0xFF) + assertEquals(SonyProtocol.RC_VIDEO_REC, b[1].toInt() and 0xFF) + } + + @Test + fun `encodeRemoteControlCommand three-byte form with parameter`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_FOCUS_NEAR, 0x20) + assertEquals(3, b.size) + assertEquals(0x02, b[0].toInt() and 0xFF) + assertEquals(SonyProtocol.RC_FOCUS_NEAR, b[1].toInt() and 0xFF) + assertEquals(0x20, b[2].toInt() and 0xFF) + } + + @Test + fun `encodeRemoteControlCommand three-byte form with parameter zero`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_ZOOM_TELE, 0) + assertEquals(3, b.size) + assertEquals(0x02, b[0].toInt() and 0xFF) + assertEquals(SonyProtocol.RC_ZOOM_TELE, b[1].toInt() and 0xFF) + assertEquals(0, b[2].toInt() and 0xFF) + } + + @Test + fun `encodeRemoteControlCommand three-byte form with parameter 0x7F`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_FOCUS_FAR, 0x7F) + assertEquals(3, b.size) + assertEquals(0x7F, b[2].toInt() and 0xFF) + } + + @Test + fun `encodeRemoteControlCommand three-byte form with parameter 0xFF`() { + val b = SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_ZOOM_WIDE, 0xFF) + assertEquals(3, b.size) + assertEquals(0xFF, b[2].toInt() and 0xFF) + } +} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolTest.kt deleted file mode 100644 index 4de482a..0000000 --- a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyProtocolTest.kt +++ /dev/null @@ -1,685 +0,0 @@ -package dev.sebastiano.camerasync.vendors.sony - -import dev.sebastiano.camerasync.domain.model.GpsLocation -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.ZonedDateTime -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Comprehensive tests for [SonyProtocol] implementation. - * - * These tests verify: - * - CC13 Time Area Setting encoding (13-byte packet for date/time sync) - * - DD11 Location packet encoding (91/95-byte packet for GPS sync) - * - Big-Endian byte order for all multi-byte values (critical for Sony protocol) - * - Decoding of both packet formats - * - Edge cases and boundary conditions - * - * See docs/sony/DATETIME_GPS_SYNC.md for protocol documentation. - */ -class SonyProtocolTest { - - // ==================== CC13 Time Area Setting Tests ==================== - - @Test - fun `encodeDateTime produces 13-byte CC13 packet`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) - val encoded = SonyProtocol.encodeDateTime(dateTime) - assertEquals("CC13 packet should be exactly 13 bytes", 13, encoded.size) - } - - @Test - fun `encodeDateTime sets correct CC13 header`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - // Bytes 0-2: Header 0x0C 0x00 0x00 - assertEquals(0x0C.toByte(), encoded[0]) - assertEquals(0x00.toByte(), encoded[1]) - assertEquals(0x00.toByte(), encoded[2]) - } - - @Test - fun `encodeDateTime uses Big-Endian for year`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - // Year 2024 = 0x07E8 in hex - // Big-Endian: high byte first = 0x07, 0xE8 - assertEquals(0x07.toByte(), encoded[3]) // High byte - assertEquals(0xE8.toByte(), encoded[4]) // Low byte - - // Verify by reading as Big-Endian - val buffer = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN) - buffer.position(3) - assertEquals(2024, buffer.short.toInt() and 0xFFFF) - } - - @Test - fun `encodeDateTime sets correct date components`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - assertEquals(12, encoded[5].toInt() and 0xFF) // Month - assertEquals(25, encoded[6].toInt() and 0xFF) // Day - assertEquals(14, encoded[7].toInt() and 0xFF) // Hour - assertEquals(30, encoded[8].toInt() and 0xFF) // Minute - assertEquals(45, encoded[9].toInt() and 0xFF) // Second - } - - @Test - fun `encodeDateTime sets DST flag to 0 for standard time`() { - // UTC has no DST - val dateTime = ZonedDateTime.of(2024, 1, 15, 12, 0, 0, 0, ZoneOffset.UTC) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - assertEquals(0x00.toByte(), encoded[10]) - } - - @Test - fun `encodeDateTime sets DST flag to 1 during daylight saving time`() { - // Use a timezone that observes DST, during summer - val dstZone = ZoneId.of("America/New_York") - val dateTime = ZonedDateTime.of(2024, 7, 15, 12, 0, 0, 0, dstZone) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - assertEquals(0x01.toByte(), encoded[10]) - } - - @Test - fun `encodeDateTime uses split format for timezone offset`() { - // UTC+8 = +8 hours, 0 minutes - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - // Byte 11: Signed hours = +8 - // Byte 12: Minutes = 0 - assertEquals(8.toByte(), encoded[11]) - assertEquals(0.toByte(), encoded[12]) - } - - @Test - fun `encodeDateTime handles negative timezone offset`() { - // UTC-5 = -5 hours, 0 minutes - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(-5)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - // Byte 11: Signed hours = -5 - // Byte 12: Minutes = 0 - assertEquals((-5).toByte(), encoded[11]) - assertEquals(0.toByte(), encoded[12]) - } - - @Test - fun `encodeDateTime handles UTC timezone`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - assertEquals(0.toByte(), encoded[11]) // Hours - assertEquals(0.toByte(), encoded[12]) // Minutes - } - - @Test - fun `encodeDateTime handles fractional timezone offset`() { - // UTC+5:30 (India) = +5 hours, 30 minutes - val dateTime = - ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHoursMinutes(5, 30)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - assertEquals(5.toByte(), encoded[11]) // Hours - assertEquals(30.toByte(), encoded[12]) // Minutes - } - - @Test - fun `encodeDateTime preserves local time not UTC`() { - // CC13 uses local time, not UTC - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - val buffer = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN) - buffer.position(3) - assertEquals(2024, buffer.short.toInt() and 0xFFFF) - assertEquals(12, buffer.get().toInt() and 0xFF) - assertEquals(25, buffer.get().toInt() and 0xFF) - assertEquals(14, buffer.get().toInt() and 0xFF) // Local hour, not UTC - assertEquals(30, buffer.get().toInt() and 0xFF) - assertEquals(45, buffer.get().toInt() and 0xFF) - } - - // ==================== DD11 Location Packet Tests ==================== - - @Test - fun `encodeLocation produces 95 bytes with timezone`() { - val location = - GpsLocation( - latitude = 37.7749, - longitude = -122.4194, - altitude = 10.0, - timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), - ) - val encoded = SonyProtocol.encodeLocation(location) - assertEquals(95, encoded.size) - } - - @Test - fun `encodeLocationPacket without timezone produces 91 bytes`() { - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = false, - ) - assertEquals(91, packet.size) - } - - @Test - fun `encodeLocationPacket uses Big-Endian for payload length`() { - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = true, - ) - - // Payload length 93 = 0x005D - // Big-Endian: 0x00, 0x5D (high byte first) - assertEquals(0x00.toByte(), packet[0]) - assertEquals(0x5D.toByte(), packet[1]) - - val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) - assertEquals(93, buffer.short.toInt()) - } - - @Test - fun `encodeLocationPacket sets correct fixed header`() { - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = true, - ) - - // Bytes 2-4: Fixed header 0x08 0x02 0xFC - assertEquals(0x08.toByte(), packet[2]) - assertEquals(0x02.toByte(), packet[3]) - assertEquals(0xFC.toByte(), packet[4]) - } - - @Test - fun `encodeLocationPacket sets timezone flag correctly`() { - val withTz = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = true, - ) - assertEquals(0x03.toByte(), withTz[5]) - - val withoutTz = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = false, - ) - assertEquals(0x00.toByte(), withoutTz[5]) - } - - @Test - fun `encodeLocationPacket sets correct padding bytes`() { - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = true, - ) - - // Bytes 6-7: Padding (zeros) - // Bytes 8-10: Fixed padding (0x10) - assertEquals(0x00.toByte(), packet[6]) - assertEquals(0x00.toByte(), packet[7]) - assertEquals(0x10.toByte(), packet[8]) - assertEquals(0x10.toByte(), packet[9]) - assertEquals(0x10.toByte(), packet[10]) - } - - @Test - fun `encodeLocationPacket uses Big-Endian for coordinates`() { - // San Francisco: 37.7749, -122.4194 - // Latitude * 10,000,000 = 377,749,000 = 0x168429B8 - // Big-Endian: 16, 84, 29, B8 - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 37.7749, - longitude = -122.4194, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = true, - ) - - val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) - buffer.position(11) - - val expectedLat = (37.7749 * 10_000_000).toInt() - val expectedLon = (-122.4194 * 10_000_000).toInt() - - assertEquals(expectedLat, buffer.int) - assertEquals(expectedLon, buffer.int) - } - - @Test - fun `encodeLocationPacket handles negative latitude correctly`() { - // Sydney: -33.8688 - val packet = - SonyProtocol.encodeLocationPacket( - latitude = -33.8688, - longitude = 151.2093, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = true, - ) - - val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) - buffer.position(11) - - val expectedLat = (-33.8688 * 10_000_000).toInt() - val expectedLon = (151.2093 * 10_000_000).toInt() - - assertEquals(expectedLat, buffer.int) - assertEquals(expectedLon, buffer.int) - } - - @Test - fun `encodeLocationPacket uses Big-Endian for year`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC) - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = dateTime, - includeTimezone = true, - ) - - // Year at offset 19 - val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) - buffer.position(19) - assertEquals(2024, buffer.short.toInt() and 0xFFFF) - } - - @Test - fun `encodeLocationPacket converts to UTC`() { - // DD11 uses UTC time (per Sony decompiled code) - // 14:30 in UTC+8 should become 06:30 UTC - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = dateTime, - includeTimezone = true, - ) - - val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) - buffer.position(19) - assertEquals(2024, buffer.short.toInt() and 0xFFFF) - assertEquals(12, buffer.get().toInt() and 0xFF) - assertEquals(25, buffer.get().toInt() and 0xFF) - assertEquals(6, buffer.get().toInt() and 0xFF) // 14 - 8 = 6 UTC - assertEquals(30, buffer.get().toInt() and 0xFF) - assertEquals(45, buffer.get().toInt() and 0xFF) - } - - @Test - fun `encodeLocationPacket uses system timezone for offset`() { - // The timezone offset now uses the system default timezone, - // not the dateTime's offset (GPS timestamps are typically UTC) - val dateTime = ZonedDateTime.now(ZoneOffset.UTC) - val packet = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 0.0, - dateTime = dateTime, - includeTimezone = true, - ) - - // Calculate expected values from system timezone - val systemZone = java.time.ZoneId.systemDefault() - val now = java.time.Instant.now() - val zoneRules = systemZone.rules - val standardOffset = zoneRules.getStandardOffset(now) - val actualOffset = zoneRules.getOffset(now) - - val expectedTzMinutes = standardOffset.totalSeconds / 60 - val expectedDstMinutes = (actualOffset.totalSeconds - standardOffset.totalSeconds) / 60 - - val buffer = ByteBuffer.wrap(packet).order(ByteOrder.BIG_ENDIAN) - buffer.position(91) - assertEquals(expectedTzMinutes.toShort(), buffer.short) // System TZ offset in minutes - assertEquals(expectedDstMinutes.toShort(), buffer.short) // DST offset - } - - @Test - fun `encodeLocationPacket handles extreme coordinates`() { - // North Pole - val northPole = - SonyProtocol.encodeLocationPacket( - latitude = 90.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = false, - ) - var buffer = ByteBuffer.wrap(northPole).order(ByteOrder.BIG_ENDIAN) - buffer.position(11) - assertEquals(900_000_000, buffer.int) - - // South Pole - val southPole = - SonyProtocol.encodeLocationPacket( - latitude = -90.0, - longitude = 0.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = false, - ) - buffer = ByteBuffer.wrap(southPole).order(ByteOrder.BIG_ENDIAN) - buffer.position(11) - assertEquals(-900_000_000, buffer.int) - - // International Date Line - val dateLine = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = 180.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = false, - ) - buffer = ByteBuffer.wrap(dateLine).order(ByteOrder.BIG_ENDIAN) - buffer.position(15) - assertEquals(1_800_000_000, buffer.int) - - val negDateLine = - SonyProtocol.encodeLocationPacket( - latitude = 0.0, - longitude = -180.0, - dateTime = ZonedDateTime.now(ZoneOffset.UTC), - includeTimezone = false, - ) - buffer = ByteBuffer.wrap(negDateLine).order(ByteOrder.BIG_ENDIAN) - buffer.position(15) - assertEquals(-1_800_000_000, buffer.int) - } - - // ==================== Decode Tests ==================== - - @Test - fun `decodeDateTime handles CC13 format`() { - val dateTime = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHours(8)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - val decoded = SonyProtocol.decodeDateTime(encoded) - - assertTrue("Should contain year", decoded.contains("2024")) - assertTrue("Should contain month-day", decoded.contains("12-25")) - assertTrue("Should contain time", decoded.contains("14:30:45")) - } - - @Test - fun `decodeDateTime handles DD11 format`() { - val location = - GpsLocation( - latitude = 37.7749, - longitude = -122.4194, - altitude = 0.0, - timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), - ) - val encoded = SonyProtocol.encodeLocation(location) - val decoded = SonyProtocol.decodeDateTime(encoded) - - // DD11 uses UTC time - assertTrue("Should contain date", decoded.contains("2024-12-25")) - assertTrue("Should contain time", decoded.contains("14:30:45")) - } - - @Test - fun `decodeDateTime returns error for invalid size`() { - val decoded = SonyProtocol.decodeDateTime(ByteArray(10)) - assertTrue("Should indicate invalid data", decoded.contains("Invalid data")) - } - - @Test - fun `decodeDateTime shows DST indicator when set`() { - val dstZone = ZoneId.of("America/New_York") - val dateTime = ZonedDateTime.of(2024, 7, 15, 12, 0, 0, 0, dstZone) - val encoded = SonyProtocol.encodeDateTime(dateTime) - val decoded = SonyProtocol.decodeDateTime(encoded) - - assertTrue("Should indicate DST", decoded.contains("DST")) - } - - @Test - fun `decodeLocation correctly decodes encoded location`() { - val location = - GpsLocation( - latitude = 37.7749, - longitude = -122.4194, - altitude = 0.0, - timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), - ) - val encoded = SonyProtocol.encodeLocation(location) - val decoded = SonyProtocol.decodeLocation(encoded) - - assertTrue("Decoded string should contain latitude", decoded.contains("37.7749")) - assertTrue("Decoded string should contain longitude", decoded.contains("-122.4194")) - assertTrue("Decoded string should contain date", decoded.contains("2024-12-25")) - } - - @Test - fun `decodeLocation handles negative coordinates`() { - val location = - GpsLocation( - latitude = -33.8688, - longitude = 151.2093, - altitude = 0.0, - timestamp = ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.UTC), - ) - val encoded = SonyProtocol.encodeLocation(location) - val decoded = SonyProtocol.decodeLocation(encoded) - - assertTrue("Should contain negative latitude", decoded.contains("-33.8688")) - assertTrue("Should contain positive longitude", decoded.contains("151.2093")) - } - - @Test - fun `decodeLocation returns error for short data`() { - val decoded = SonyProtocol.decodeLocation(ByteArray(10)) - assertTrue("Should indicate invalid data", decoded.contains("Invalid data")) - } - - // ==================== Round-trip Tests ==================== - - @Test - fun `encodeDateTime handles fractional timezone offset near zero`() { - // UTC-0:30 = 0 hours, -30 minutes - // This follows Sony's buggy behavior: offsetHours becomes 0, losing the negative sign. - val dateTime = - ZonedDateTime.of(2024, 12, 25, 14, 30, 45, 0, ZoneOffset.ofHoursMinutes(0, -30)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - val decoded = SonyProtocol.decodeDateTime(encoded) - - // It incorrectly decodes as +00:30 because the sign was lost in encoding - assertTrue( - "Decoded string should contain UTC+00:30 due to Sony's bug, but was: $decoded", - decoded.contains("+00:30"), - ) - } - - @Test - fun `CC13 encode-decode round trip preserves data`() { - val dateTime = ZonedDateTime.of(2024, 6, 15, 10, 25, 30, 0, ZoneOffset.ofHours(-5)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - // Manually verify the round trip by reading bytes - val buffer = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN) - buffer.position(3) - assertEquals(2024, buffer.short.toInt() and 0xFFFF) - assertEquals(6, buffer.get().toInt() and 0xFF) - assertEquals(15, buffer.get().toInt() and 0xFF) - assertEquals(10, buffer.get().toInt() and 0xFF) - assertEquals(25, buffer.get().toInt() and 0xFF) - assertEquals(30, buffer.get().toInt() and 0xFF) - buffer.get() // DST flag - assertEquals(-5, buffer.get().toInt()) // Hours (signed) - assertEquals(0, buffer.get().toInt() and 0xFF) // Minutes - } - - @Test - fun `DD11 encode-decode round trip preserves coordinates`() { - val location = - GpsLocation( - latitude = 51.5074, - longitude = -0.1278, - altitude = 0.0, - timestamp = ZonedDateTime.of(2024, 3, 20, 15, 45, 0, 0, ZoneOffset.UTC), - ) - val encoded = SonyProtocol.encodeLocation(location) - val decoded = SonyProtocol.decodeLocation(encoded) - - // Allow for floating-point precision differences - assertTrue("Should contain latitude ~51.5074", decoded.contains("51.507")) - assertTrue("Should contain longitude ~-0.1278", decoded.contains("-0.127")) - assertTrue("Should contain date", decoded.contains("2024-03-20")) - assertTrue("Should contain time", decoded.contains("15:45:00")) - } - - // ==================== Configuration Parsing Tests ==================== - - @Test - fun `parseConfigRequiresTimezone returns true when bit 1 is set`() { - // byte[4] = 0x02 (bit 1 set) → timezone supported, use 95-byte payload - val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x02, 0x00) - assertTrue(SonyProtocol.parseConfigRequiresTimezone(config)) - } - - @Test - fun `parseConfigRequiresTimezone returns true when bit 1 is set with other bits`() { - // byte[4] = 0x06 (bits 1 and 2 set) → timezone supported, use 95-byte payload - val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x06, 0x00) - assertTrue(SonyProtocol.parseConfigRequiresTimezone(config)) - } - - @Test - fun `parseConfigRequiresTimezone returns false when bit 1 is not set`() { - // byte[4] = 0x04 (bit 2 set, but not bit 1) → timezone NOT supported, use 91-byte payload - val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x04, 0x00) - assertFalse(SonyProtocol.parseConfigRequiresTimezone(config)) - } - - @Test - fun `parseConfigRequiresTimezone returns false when byte 4 is zero`() { - // byte[4] = 0x00 (no bits set) → timezone NOT supported, use 91-byte payload - val config = byteArrayOf(0x06, 0x10, 0x00, 0x9C.toByte(), 0x00, 0x00) - assertFalse(SonyProtocol.parseConfigRequiresTimezone(config)) - } - - @Test - fun `parseConfigRequiresTimezone returns false for short data`() { - assertFalse(SonyProtocol.parseConfigRequiresTimezone(byteArrayOf(0x01, 0x02))) - } - - // ==================== Helper Command Tests ==================== - - @Test - fun `createStatusNotifyEnable returns correct bytes`() { - val expected = byteArrayOf(0x03, 0x01, 0x02, 0x01) - assertArrayEquals(expected, SonyProtocol.createStatusNotifyEnable()) - } - - @Test - fun `createStatusNotifyDisable returns correct bytes`() { - val expected = byteArrayOf(0x03, 0x01, 0x02, 0x00) - assertArrayEquals(expected, SonyProtocol.createStatusNotifyDisable()) - } - - @Test - fun `createPairingInit returns correct bytes`() { - val expected = byteArrayOf(0x06, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00) - assertArrayEquals(expected, SonyProtocol.createPairingInit()) - } - - @Test - fun `getPairingInitData returns same as createPairingInit`() { - assertArrayEquals(SonyProtocol.createPairingInit(), SonyProtocol.getPairingInitData()) - } - - // ==================== Geo-tagging Tests ==================== - - @Test - fun `encodeGeoTaggingEnabled returns empty array`() { - assertEquals(0, SonyProtocol.encodeGeoTaggingEnabled(true).size) - assertEquals(0, SonyProtocol.encodeGeoTaggingEnabled(false).size) - } - - @Test - fun `decodeGeoTaggingEnabled returns false`() { - assertFalse(SonyProtocol.decodeGeoTaggingEnabled(byteArrayOf(0x01))) - assertFalse(SonyProtocol.decodeGeoTaggingEnabled(byteArrayOf())) - } - - // ==================== Byte Order Verification Tests ==================== - - @Test - fun `verify Big-Endian is used throughout - explicit byte check`() { - val dateTime = ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC) - val location = - GpsLocation( - latitude = 1.0, // 10,000,000 = 0x00989680 - longitude = 2.0, // 20,000,000 = 0x01312D00 - altitude = 0.0, - timestamp = dateTime, - ) - val encoded = SonyProtocol.encodeLocation(location) - - // Year 2025 = 0x07E9 - // If Big-Endian: bytes would be 0x07, 0xE9 - assertEquals("Year high byte should be 0x07 for Big-Endian", 0x07.toByte(), encoded[19]) - assertEquals("Year low byte should be 0xE9 for Big-Endian", 0xE9.toByte(), encoded[20]) - - // Latitude 10,000,000 = 0x00989680 - // Big-Endian: 0x00, 0x98, 0x96, 0x80 - assertEquals(0x00.toByte(), encoded[11]) - assertEquals(0x98.toByte(), encoded[12]) - assertEquals(0x96.toByte(), encoded[13]) - assertEquals(0x80.toByte(), encoded[14]) - - // Longitude 20,000,000 = 0x01312D00 - // Big-Endian: 0x01, 0x31, 0x2D, 0x00 - assertEquals(0x01.toByte(), encoded[15]) - assertEquals(0x31.toByte(), encoded[16]) - assertEquals(0x2D.toByte(), encoded[17]) - assertEquals(0x00.toByte(), encoded[18]) - } - - @Test - fun `verify CC13 uses Big-Endian - explicit byte check`() { - // Year 2025 = 0x07E9 - // UTC+9 = +9 hours, 0 minutes (split format) - val dateTime = ZonedDateTime.of(2025, 1, 1, 12, 0, 0, 0, ZoneOffset.ofHours(9)) - val encoded = SonyProtocol.encodeDateTime(dateTime) - - // Year (bytes 3-4) - Big-Endian - assertEquals("Year high byte", 0x07.toByte(), encoded[3]) - assertEquals("Year low byte", 0xE9.toByte(), encoded[4]) - - // Timezone offset (bytes 11-12) - Split format - assertEquals("TZ hours", 9.toByte(), encoded[11]) - assertEquals("TZ minutes", 0.toByte(), encoded[12]) - } -} diff --git a/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegateTest.kt b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegateTest.kt new file mode 100644 index 0000000..5bf82a6 --- /dev/null +++ b/app/src/test/kotlin/dev/sebastiano/camerasync/vendors/sony/SonyRemoteControlDelegateTest.kt @@ -0,0 +1,531 @@ +package dev.sebastiano.camerasync.vendors.sony + +import com.juul.kable.Characteristic +import com.juul.kable.Peripheral +import com.juul.kable.WriteType +import dev.sebastiano.camerasync.domain.model.Camera +import dev.sebastiano.camerasync.domain.model.CameraMode +import dev.sebastiano.camerasync.domain.model.CaptureStatus +import dev.sebastiano.camerasync.domain.model.FocusStatus +import dev.sebastiano.camerasync.domain.model.RecordingStatus +import dev.sebastiano.camerasync.domain.model.ShutterStatus +import dev.sebastiano.camerasync.domain.vendor.ShootingConnectionMode +import dev.sebastiano.camerasync.testutils.WriteRecorder +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.uuid.ExperimentalUuidApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tests for [SonyRemoteControlDelegate] BLE flows using Mockk. */ +@OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class) +class SonyRemoteControlDelegateTest { + + private fun createCamera(): Camera = + Camera( + identifier = "sony-ble-id", + name = "ILCE-7M4", + macAddress = "AA:BB:CC:DD:EE:FF", + vendor = SonyCameraVendor, + ) + + private fun createPeripheral( + observeFlow: kotlinx.coroutines.flow.Flow, + writeRecorder: WriteRecorder? = null, + ): Peripheral = + mockk(relaxed = true) { + every { observe(any(), any()) } returns observeFlow + if (writeRecorder != null) { + coEvery { write(any(), any(), any()) } answers + { + writeRecorder.record(firstArg(), secondArg(), thirdArg()) + } + } else { + coEvery { write(any(), any(), any()) } returns Unit + } + } + + // ==================== triggerCapture (BLE) — event-driven ==================== + + /** FF02 payloads per BLE_STATE_MONITORING.md: [0x02, type, value]. */ + private fun focusAcquiredPayload(): ByteArray = byteArrayOf(0x02, 0x3F, 0x20) + + private fun shutterActivePayload(): ByteArray = byteArrayOf(0x02, 0xA0.toByte(), 0x20) + + @Test + fun `triggerCapture follows event-driven sequence per doc half down then wait focus then full down then wait shutter then release`() = + runTest { + val ff02Flow = flowOf(focusAcquiredPayload(), shutterActivePayload()) + val recorder = WriteRecorder() + val peripheral = createPeripheral(ff02Flow, recorder) + val delegate = SonyRemoteControlDelegate(peripheral, createCamera()) + + delegate.triggerCapture() + + val finalWrites = recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID) + assertEquals(4, finalWrites.size) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_PRESS), + finalWrites[0], + ) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_PRESS), + finalWrites[1], + ) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_RELEASE), + finalWrites[2], + ) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_RELEASE), + finalWrites[3], + ) + } + + @Test + fun `triggerCapture uses WithoutResponse for all writes`() = runTest { + val ff02Flow = flowOf(focusAcquiredPayload(), shutterActivePayload()) + val recorder = WriteRecorder() + val peripheral = createPeripheral(ff02Flow, recorder) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.triggerCapture() + + val types = recorder.writeTypesFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID) + assertEquals(4, types.size) + assertTrue(types.all { it == WriteType.WithoutResponse }) + } + + /** + * Regression: FF02 shared flow must use replay=1 so notifications emitted between the BLE write + * and the filter+first subscriber becoming active are not dropped. With replay=0, a fast camera + * response in that window would be lost and we'd fall back to timeouts. + */ + @Test + fun `triggerCapture receives FF02 notifications that arrive before subscriber with replay`() = + runTest { + val ff02Flow = flow { + emit(focusAcquiredPayload()) + delay(100) + emit(shutterActivePayload()) + } + val recorder = WriteRecorder() + val dispatcher = StandardTestDispatcher(testScheduler) + val peripheral = createPeripheral(ff02Flow, recorder) + val delegate = + SonyRemoteControlDelegate( + peripheral, + createCamera(), + captureDispatcher = dispatcher, + ) + + val startTime = testScheduler.currentTime + launch { delegate.triggerCapture() } + advanceTimeBy(150) + advanceUntilIdle() + val elapsed = testScheduler.currentTime - startTime + + assertEquals( + "Full sequence (half → focus → full → shutter → release) must complete", + 4, + recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID).size, + ) + assertTrue( + "Must complete without relying on 3s+5s timeouts (replay=1 delivers early emissions); elapsed=${elapsed}ms", + elapsed < 2_000L, + ) + } + + /** + * Uses a test dispatcher so that [advanceTimeBy] controls the timeout; without it the test + * would wait 8s real time and feel like it "takes forever". + * + * The test dispatcher is passed to [SonyRemoteControlDelegate] via the `captureDispatcher` + * parameter to ensure timeouts use virtual time for deterministic testing. + */ + @Test + fun `triggerCapture proceeds after focus and shutter timeouts when no FF02 events`() = runTest { + val ff02Flow = flowOf() + val recorder = WriteRecorder() + val peripheral = createPeripheral(ff02Flow, recorder) + val dispatcher = StandardTestDispatcher(testScheduler) + val delegate = + SonyRemoteControlDelegate(peripheral, createCamera(), captureDispatcher = dispatcher) + + val startVirtualTime = testScheduler.currentTime + val captureJob = launch { delegate.triggerCapture() } + runCurrent() + val step1 = recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID).size + assertEquals( + "After half-press, delegate waits for focus (1 write so far, got $step1)", + 1, + step1, + ) + + advanceTimeBy(3_000L) + runCurrent() + assertEquals( + "After focus timeout (3s), full press is written", + 2, + recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID).size, + ) + + advanceTimeBy(5_000L) + runCurrent() + assertEquals( + "After shutter timeout (5s), release sequence is written", + 4, + recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID).size, + ) + advanceUntilIdle() + captureJob.join() + + val elapsedVirtualMs = testScheduler.currentTime - startVirtualTime + assertTrue( + "Trigger sequence must complete in virtual time (~8s); elapsed=${elapsedVirtualMs}ms. " + + "If this fails, the test dispatcher may not be applied and timeouts use real time.", + elapsedVirtualMs in 7_000L..10_000L, + ) + } + + /** + * Regression: shareIn must use SharingStarted.WhileSubscribed (not Eagerly) so that when the + * filter().first() subscribers complete, the upstream collector stops and coroutineScope can + * finish. With Eagerly + an infinite observe() flow (like real peripheral.observe()), + * triggerCapture() would hang forever. + */ + @Test + fun `triggerCapture returns when observe flow is infinite and uses WhileSubscribed`() = + runTest { + val infiniteFf02Flow = flow { + emit(focusAcquiredPayload()) + emit(shutterActivePayload()) + awaitCancellation() + } + val recorder = WriteRecorder() + val peripheral = createPeripheral(infiniteFf02Flow, recorder) + val dispatcher = StandardTestDispatcher(testScheduler) + val delegate = + SonyRemoteControlDelegate( + peripheral, + createCamera(), + captureDispatcher = dispatcher, + ) + + val captureJob = launch { delegate.triggerCapture() } + advanceUntilIdle() + captureJob.join() + + assertEquals(4, recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID).size) + } + + @Test + fun `triggerCapture maintains subscription during write suspension gap`() = runTest { + // Setup: + // We need a flow that we can emit to manually. + val ff02Flow = MutableSharedFlow(replay = 0) // No replay in the source flow + + // We need to detect if the subscription was cancelled. + // We can infer this if we emit the shutter active event *during* the write suspension, + // and it is NOT received by the delegate (causing it to timeout). + + var writeCount = 0 + val peripheral = + mockk(relaxed = true) { + every { observe(any(), any()) } returns ff02Flow + coEvery { write(any(), any(), any()) } coAnswers + { + writeCount++ + if (writeCount == 2) { + // This is the "Shutter Full Down" write (step 3). + // Simulate a delay in the write operation, which allows the coroutine + // scheduler + // to potentially process the unsubscription if stopTimeoutMillis=0. + delay(100) + + // Emit the shutter active notification *while* the write is still + // "suspending" + // (or right after, but before the next collection starts if + // unsubscription happened). + // If the subscription was torn down, this emission will be missed by + // the shareIn operator + // (unless replay covers it, but replay covers new subscribers, not + // missed upstream emissions if upstream was cancelled). + ff02Flow.emit(shutterActivePayload()) + } + } + } + + val dispatcher = StandardTestDispatcher(testScheduler) + val delegate = + SonyRemoteControlDelegate(peripheral, createCamera(), captureDispatcher = dispatcher) + + val startTime = testScheduler.currentTime + val captureJob = launch { delegate.triggerCapture() } + + // Advance to start the flow + runCurrent() + + // 1. Emit focus acquired to pass the first gate + ff02Flow.emit(focusAcquiredPayload()) + + // 2. Advance time to let the first filter pass and reach the second write + // The second write (Full Press) will suspend for 100ms and emit the shutter active payload. + advanceUntilIdle() + + // If the notification was caught, the sequence should proceed immediately after the write + // finishes. + // If it was lost, we'll hit the 5000ms timeout. + + val elapsed = testScheduler.currentTime - startTime + + // Verify we finished quickly (successful capture of notification) rather than waiting for + // timeout. + // 100ms for write delay + small buffer. Timeout is 5000ms. + assertTrue( + "Should finish quickly (< 1000ms) but took $elapsed ms. " + + "This implies the shutter notification was missed and we hit the timeout.", + elapsed < 4000, + ) + + captureJob.cancel() + } + + // ==================== startBulbExposure / stopBulbExposure ==================== + + @Test + fun `startBulbExposure writes shutter full press`() = runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.startBulbExposure() + + val ff01Data = recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID) + assertEquals(1, ff01Data.size) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_PRESS), + ff01Data[0], + ) + } + + @Test + fun `stopBulbExposure writes shutter full release`() = runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.stopBulbExposure() + + val ff01Data = recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID) + assertEquals(1, ff01Data.size) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_FULL_RELEASE), + ff01Data[0], + ) + } + + // ==================== observe flows — FF02 and CC09 ==================== + + @Test + fun `observeCaptureStatus maps FF02 shutter 0xA0 0x20 to Capturing`() = runTest { + val ff02Payload = byteArrayOf(0x02, 0xA0.toByte(), 0x20) + val peripheral = createPeripheral(flowOf(ff02Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val status = delegate.observeCaptureStatus().first() + + assertEquals(CaptureStatus.Capturing, status) + } + + @Test + fun `observeCaptureStatus maps FF02 shutter 0xA0 0x00 to Idle`() = runTest { + val ff02Payload = byteArrayOf(0x02, 0xA0.toByte(), 0x00) + val peripheral = createPeripheral(flowOf(ff02Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val status = delegate.observeCaptureStatus().first() + + assertEquals(CaptureStatus.Idle, status) + } + + @Test + fun `observeCameraMode maps CC09 tag 0x0008 value 1 to MOVIE`() = runTest { + val cc09Payload = byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x01) + val peripheral = createPeripheral(flowOf(cc09Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val mode = delegate.observeCameraMode().first() + + assertEquals(CameraMode.MOVIE, mode) + } + + @Test + fun `observeCameraMode filters value 0 - tag 0x0008 is recording state not mode`() = runTest { + // Value 0 = not recording; cannot infer mode (still vs movie idle). decodeCameraStatus + // returns UNKNOWN, which observeCameraMode filters. Emit value 0 then value 1; only MOVIE + // should be emitted (value 0 yields UNKNOWN, filtered). + val cc09Payloads = + flowOf( + byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x00), + byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x01), + ) + val peripheral = createPeripheral(cc09Payloads) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val modes = delegate.observeCameraMode().take(2).toList() + + assertEquals(1, modes.size) + assertEquals(CameraMode.MOVIE, modes.single()) + } + + @Test + fun `observeCameraMode filters out UNKNOWN so time-completion payloads do not emit`() = + runTest { + // CC09 is dual-purpose: time-setting (tag 0x0005) and camera status (tag 0x0008). + // Emit time-completion first (decodeCameraStatus -> UNKNOWN), then valid 0x0008 + // (MOVIE). + // Filter must drop UNKNOWN so only MOVIE is emitted; no spurious UI update. + val timeCompletionPayload = + byteArrayOf(0x00, 0x05, 0x00, 0x01, 0x01) // tag 0x0005, done + val moviePayload = byteArrayOf(0x00, 0x08, 0x00, 0x01, 0x01) + val cc09Flow = flowOf(timeCompletionPayload, moviePayload) + val peripheral = createPeripheral(cc09Flow) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val modes = delegate.observeCameraMode().take(2).toList() + + assertEquals(1, modes.size) + assertEquals(CameraMode.MOVIE, modes.single()) + } + + @Test + fun `observeFocusStatus maps FF02 focus 0x3F 0x20 to LOCKED`() = runTest { + val ff02Payload = byteArrayOf(0x02, 0x3F, 0x20.toByte()) + val peripheral = createPeripheral(flowOf(ff02Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val flow = delegate.observeFocusStatus() + assertNotNull(flow) + val status = flow.first() + + assertEquals(FocusStatus.LOCKED, status) + } + + @Test + fun `observeShutterStatus maps FF02 0xA0 0x20 to ACTIVE`() = runTest { + val ff02Payload = byteArrayOf(0x02, 0xA0.toByte(), 0x20) + val peripheral = createPeripheral(flowOf(ff02Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val flow = delegate.observeShutterStatus() + assertNotNull(flow) + val status = flow.first() + + assertEquals(ShutterStatus.ACTIVE, status) + } + + @Test + fun `observeRecordingStatus maps FF02 0xD5 0x20 to RECORDING`() = runTest { + val ff02Payload = byteArrayOf(0x02, 0xD5.toByte(), 0x20) + val peripheral = createPeripheral(flowOf(ff02Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val flow = delegate.observeRecordingStatus() + assertNotNull(flow) + val status = flow.first() + + assertEquals(RecordingStatus.RECORDING, status) + } + + @Test + fun `observeExposureMode emits nothing - BLE only, Wi-Fi PTP later`() = runTest { + val peripheral = createPeripheral(flowOf(ByteArray(0))) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val emitted = delegate.observeExposureMode().toList() + + assertTrue(emitted.isEmpty()) + } + + @Test + fun `observeDriveMode emits nothing - BLE only, Wi-Fi PTP later`() = runTest { + val peripheral = createPeripheral(flowOf(ByteArray(0))) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val emitted = delegate.observeDriveMode().toList() + + assertTrue(emitted.isEmpty()) + } + + @Test + fun `observeBatteryLevel decodes CC10 payload`() = runTest { + // CC10: enable, support, position, status, then 4-byte big-endian percentage + val cc10Payload = byteArrayOf(1, 1, 1, 0, 0, 0, 0, 75) + val peripheral = createPeripheral(flowOf(cc10Payload)) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + val info = delegate.observeBatteryLevel().first() + + assertEquals(75, info.levelPercentage) + } + + @Test + fun `connectionMode is BLE_ONLY by default`() = runTest { + val peripheral = createPeripheral(flowOf(ByteArray(0))) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + assertEquals(ShootingConnectionMode.BLE_ONLY, delegate.connectionMode.value) + } + + @Test + fun `halfPressAF writes RC_SHUTTER_HALF_PRESS`() = runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.halfPressAF() + + val data = recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID) + assertEquals(1, data.size) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_SHUTTER_HALF_PRESS), + data[0], + ) + } + + @Test + fun `toggleVideoRecording writes RC_VIDEO_REC`() = runTest { + val recorder = WriteRecorder() + val peripheral = createPeripheral(flowOf(ByteArray(0)), recorder) + val delegate = SonyRemoteControlDelegate(peripheral, camera = createCamera()) + + delegate.toggleVideoRecording() + + val data = recorder.dataFor(SonyGattSpec.REMOTE_COMMAND_CHARACTERISTIC_UUID) + assertEquals(1, data.size) + assertArrayEquals( + SonyProtocol.encodeRemoteControlCommand(SonyProtocol.RC_VIDEO_REC), + data[0], + ) + } +} diff --git a/camerasync.af b/camerasync.af new file mode 100644 index 0000000000000000000000000000000000000000..9a6cb673ba5d1840381b5e29a8eead824f7811cb GIT binary patch literal 53674 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 != null) { + return + } + if (expression.getParentOfType(true) != null) { + return + } + val packageDirectiveText = expression.containingKtFile.packageDirective?.text + if (packageDirectiveText != null && packageDirectiveText.contains(expression.text)) { + return + } + val fileText = expression.containingKtFile.text + if (isInsidePackageDirective(expression)) { + return + } + if (isOnPackageLine(fileText, expression.textOffset)) { + return + } + val text = expression.text + if (text.startsWith(packagePrefix)) { + report(CodeSmell(issue, Entity.from(expression), "Use an import for $packagePrefix")) + } + super.visitDotQualifiedExpression(expression) + } + + override fun visitUserType(type: KtUserType) { + if (type.getParentOfType(true) != null) { + return + } + if (type.getParentOfType(true) != null) { + return + } + val packageDirectiveText = type.containingKtFile.packageDirective?.text + if (packageDirectiveText != null && packageDirectiveText.contains(type.text)) { + return + } + val fileText = type.containingKtFile.text + if (isInsidePackageDirective(type)) { + return + } + if (isOnPackageLine(fileText, type.textOffset)) { + return + } + val text = type.text + if (text.startsWith(packagePrefix)) { + report(CodeSmell(issue, Entity.from(type), "Use an import for $packagePrefix")) + } + super.visitUserType(type) + } + + private fun isInsidePackageDirective(element: KtElement): Boolean { + val fileText = element.containingKtFile.text + if (!fileText.startsWith("package ")) return false + val lineEnd = fileText.indexOf('\n').let { if (it == -1) fileText.length else it } + return element.textRange.startOffset <= lineEnd + } + + private fun isOnPackageLine(fileText: String, offset: Int): Boolean { + val safeOffset = offset.coerceIn(0, fileText.length) + val lineStart = fileText.lastIndexOf('\n', safeOffset).let { if (it == -1) 0 else it + 1 } + val lineEnd = fileText.indexOf('\n', safeOffset).let { if (it == -1) fileText.length else it } + val lineText = fileText.substring(lineStart, lineEnd) + return lineText.trimStart().startsWith("package ") + } +} diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 0000000..6c93afa --- /dev/null +++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +dev.sebastiano.camerasync.detekt.CameraSyncRuleSetProvider diff --git a/detekt-rules/src/test/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRuleTest.kt b/detekt-rules/src/test/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRuleTest.kt new file mode 100644 index 0000000..2cc2fe3 --- /dev/null +++ b/detekt-rules/src/test/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRuleTest.kt @@ -0,0 +1,79 @@ +package dev.sebastiano.camerasync.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.test.compileAndLint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NoFullyQualifiedAppReferenceRuleTest { + @Test + fun `reports fully qualified type references`() { + val code = + """ + package sample + + class Example { + val repo: dev.sebastiano.camerasync.data.Foo = Foo() + } + """ + .trimIndent() + + val findings = NoFullyQualifiedAppReferenceRule(Config.empty).compileAndLint(code) + + assertTrue(findings.isNotEmpty()) + assertTrue(findings.all { it.id == "NoFullyQualifiedAppReference" }) + } + + @Test + fun `reports fully qualified call references`() { + val code = + """ + package sample + + class Example { + val repo = dev.sebastiano.camerasync.data.Foo() + } + """ + .trimIndent() + + val findings = NoFullyQualifiedAppReferenceRule(Config.empty).compileAndLint(code) + + assertTrue(findings.isNotEmpty()) + assertTrue(findings.all { it.id == "NoFullyQualifiedAppReference" }) + } + + @Test + fun `ignores package directive references`() { + val code = + """ + package dev.sebastiano.camerasync.sample + + class Example + """ + .trimIndent() + + val findings = NoFullyQualifiedAppReferenceRule(Config.empty).compileAndLint(code) + + assertEquals(0, findings.size) + } + + @Test + fun `ignores imports`() { + val code = + """ + package sample + + import dev.sebastiano.camerasync.data.Foo + + class Example { + val repo: Foo = Foo() + } + """ + .trimIndent() + + val findings = NoFullyQualifiedAppReferenceRule(Config.empty).compileAndLint(code) + + assertEquals(0, findings.size) + } +} diff --git a/detekt.yml b/detekt.yml index c16b586..6684d6f 100644 --- a/detekt.yml +++ b/detekt.yml @@ -229,3 +229,7 @@ Compose: active: true ViewModelInjection: active: true + +camerasync: + NoFullyQualifiedAppReference: + active: true diff --git a/docs/WIFI_REMOTE_SHUTTER_PLAN.md b/docs/WIFI_REMOTE_SHUTTER_PLAN.md new file mode 100644 index 0000000..7bf7fa4 --- /dev/null +++ b/docs/WIFI_REMOTE_SHUTTER_PLAN.md @@ -0,0 +1,201 @@ +# Wi‑Fi–Based Remote Shutter: Remaining Implementation Plan + +This document describes the **remaining work** to support remote shutter (and related “Full” mode features) over Wi‑Fi, so it can be implemented at a later date without the original design plan. The capability model, delegate interface, BLE-only remote shutter, and UI scaffolding are already in place. + +--- + +## 1. What’s Already Done + +- **Remote control capability model** + `RemoteControlCapabilities` (and sub-types like `RemoteCaptureCapabilities`, `ConnectionModeSupport`, `SyncCapabilities`) are defined. Vendors declare `connectionModeSupport.wifiAddsFeatures = true` and which features require Wi‑Fi. + +- **RemoteControlDelegate interface** + Includes `connectionMode: StateFlow`, `triggerCapture()`, `connectWifi()`, `disconnectWifi()`, and Wi‑Fi–only extensions (e.g. `touchAF`, `observeLiveView()`). See `app/.../domain/vendor/RemoteControlDelegate.kt`. + +- **BLE-only remote shutter (Sony)** + `SonyRemoteControlDelegate` implements the event-driven sequence (half-press → focus/timeout → full press → shutter/timeout → release) over BLE FF01/FF02. Timeout tests use an injected `captureScope` for virtual time. + +- **BLE-only remote shutter (Ricoh)** + `RicohRemoteControlDelegate` triggers capture via BLE write to the command characteristic. + +- **Remote shooting UI** + `RemoteShootingScreen` shows connection banner, status bar (battery, storage), capture button, shooting settings (mode/drive/focus icons), and conditional sections for live view / advanced controls / image control when capabilities and connection mode allow. **Full mode (Wi‑Fi) is intentionally not switchable yet:** the banner shows “Full mode (Wi‑Fi) coming soon” and the “Connect Wi‑Fi” button is not shown. + +- **Migration** + All sync-related logic uses `getRemoteControlCapabilities().sync`; legacy `CameraCapabilities` and `getCapabilities()` have been removed. + +--- + +## 2. High-Level Remaining Goal + +Enable **ShootingConnectionMode.FULL** (Wi‑Fi) so that: + +1. The user can tap “Connect Wi‑Fi” (or equivalent) in the remote shooting screen. +2. The app performs vendor-specific Wi‑Fi activation and credential exchange over BLE, connects the device to the camera’s AP (or network), then establishes the data channel (PTP/IP for Sony, HTTP for Ricoh). +3. When in FULL mode, `triggerCapture()` and other delegate methods use the Wi‑Fi path (PTP/IP or HTTP) instead of BLE where applicable. +4. The UI can show live view, touch AF, image browsing, etc., when the vendor supports them and the mode is FULL. +5. The user can disconnect Wi‑Fi to return to BLE-only and save battery. + +--- + +## 3. Prerequisites and Shared Infrastructure + +### 3.1 Android Wi‑Fi / Network Binding + +- Use `ConnectivityManager.requestNetwork()` with `WifiNetworkSpecifier` (SSID, passphrase, optional BSSID) to connect to the camera’s AP without taking over global routing. +- Prefer **app-only routing** so only the app’s sockets use the camera network: `ConnectivityManager.bindProcessToNetwork(network)` (Android 12+). See Sony `REMOTE_CAPTURE.md` §5.4. +- Handle timeouts (e.g. 30 s Wi‑Fi on, 15 s credential read), and errors: `ConnectionTimeOut`, `WifiOff`, `ErrorAuthenticating`, etc. +- **Permissions:** `ACCESS_FINE_LOCATION` (often required for Wi‑Fi scanning), `CHANGE_WIFI_STATE`, `ACCESS_WIFI_STATE`. Ensure manifest and runtime permissions are documented and requested where needed. + +### 3.2 Credential and State Storage (Optional but Recommended) + +- Cache SSID/passphrase per camera (e.g. by MAC or device id) so the user doesn’t re-enter after the first successful Wi‑Fi connection. Persist only in app-private storage; do not log credentials. +- Consider storing “last used network” or “prefer 5 GHz” per vendor if the protocol supports it (e.g. Ricoh WLAN frequency, Sony read of `CCAB`). + +### 3.3 Re-enable Full-Mode Switch in UI + +- In `RemoteShootingScreen.kt`, `ConnectionBanner`: replace the “Full mode (Wi‑Fi) coming soon” text with the actual “Connect Wi‑Fi for Full Features” button that calls `onConnectWifi` (already passed from the parent; only the button is currently hidden). +- Ensure `delegate.connectWifi()` is invoked from a coroutine (already wired as `scope.launch { delegate.connectWifi() }` when the button exists). +- After a successful `connectWifi()`, `delegate.connectionMode` will emit `ShootingConnectionMode.FULL`; the UI already branches on `connectionMode` for live view, advanced controls, and image control. +- Keep the “Disconnect Wi‑Fi (save battery)” path calling `delegate.disconnectWifi()` when mode is FULL. + +--- + +## 4. Sony: Wi‑Fi Activation and PTP/IP Remote Shutter + +### 4.1 Reference Documentation + +- **`docs/sony/REMOTE_CAPTURE.md`** — End-to-end flow: BLE discovery → Wi‑Fi activation (CC08, CC06/CC07) → Wi‑Fi association → SSDP → PTP/IP init → remote capture (S1/S2, RequestOneShooting, MovieRec). +- **`docs/sony/BLE_STATE_MONITORING.md`** — BLE Remote Control Service (FF00) used for BLE-only shutter; Camera Control Service (CC00) for Wi‑Fi handoff. +- **`docs/sony/README.md`** — Document index and quick refs. + +### 4.2 BLE Wi‑Fi Activation (Sony) + +- **Service:** `8000CC00-CC00-FFFF-FFFF-FFFFFFFFFFFF` (Camera Control Service). +- **Turn Wi‑Fi on:** Write `{0x01}` to characteristic `0000CC08`. Wait for camera to report Wi‑Fi on (observe Wi‑Fi status if available); timeout ~30 s. +- **Read credentials:** Read `0000CC06` (SSID), `0000CC07` (password), optionally `0000CC0C` (BSSID). SSID/password start at byte index 3 (US-ASCII). Timeout ~15 s. +- **Error handling:** Timeout, GATT errors, camera “NoMedia” or similar. Map to user-visible messages or retry policy. + +### 4.3 Wi‑Fi Association and SSDP (Sony) + +- Create `WifiNetworkSpecifier` with SSID and WPA2 passphrase (and BSSID if read). Use `ConnectivityManager.requestNetwork()` and, on success, `bindProcessToNetwork(network)` so only the app uses the camera AP. +- **SSDP discovery:** Send M-SEARCH for `urn:schemas-sony-com:service:ScalarWebAPI:1` (multicast `239.255.255.250:1900`). Parse `LOCATION` header to get `http://:8080/description.xml`. Extract camera IP for PTP/IP (TCP port 15740). + +### 4.4 PTP/IP Session (Sony) + +- **Port:** TCP 15740. +- **Command channel:** Send `InitCommandRequest` (Type 0x01) with client GUID (16 bytes, stable per install), friendly name (UTF-16LE), protocol version `0x00010000`. Receive `InitCommandAck` (Type 0x02) and connection number. +- **Event channel:** Second TCP connection to same port. Send `InitEventRequest` (Type 0x03) with connection number; receive `InitEventAck` (Type 0x04). **Important:** Respond to `ProbeRequest` (Type 0x0D) with `ProbeResponse` (Type 0x0E) on the event channel; otherwise the session can fail. +- **Function mode:** Use `REMOTE_CONTROL_MODE` (or `REMOTE_CONTROL_WITH_TRANSFER_MODE` if the camera supports it and transfer is needed later). Session and mode setup are described in REMOTE_CAPTURE.md §7. + +### 4.5 Remote Shutter Over PTP/IP (Sony) + +- **Control codes:** Sent via PTP/IP (e.g. `SDIO_ControlDevice`). Relevant codes: + - **RequestOneShooting** (53959) — Single-shot capture (no separate S1/S2). + - **S1Button** (53953) — Half-press AF (press=1, release=2). + - **S2Button** (53954) — Full-press shutter (press=1, release=2). +- **Typical still capture:** Either `RequestOneShooting`, or `pressButton(S1)` → (optional wait for focus) → `pressButton(S2)` → `releaseButton(S2)` → `releaseButton(S1)`. +- **ShootingController** (from Creators’ App) maintains shooting mode (Still, Movie, Continuous, Bulb, etc.); different modes may require different sequences. Start with Still mode: `RequestOneShooting` or S1/S2 pair. +- **Movie:** `MovieRecButton` (53960) toggle to start/stop recording. + +### 4.6 Implementation Tasks (Sony) + +1. **Implement `SonyRemoteControlDelegate.connectWifi()`** + Sequence: (1) Write CC08 `{0x01}` and wait for Wi‑Fi on, (2) Read CC06/CC07/CC0C and parse SSID/password/BSSID, (3) Call into a shared or Sony-specific Wi‑Fi connector that uses `ConnectivityManager.requestNetwork` + `bindProcessToNetwork`, (4) Run SSDP discovery to get camera IP, (5) Open PTP/IP command and event channels and perform init handshake (including ProbeResponse), (6) Set function mode to REMOTE_CONTROL, (7) set `_connectionMode.value = ShootingConnectionMode.FULL`. + +2. **Implement `SonyRemoteControlDelegate.disconnectWifi()`** + Tear down PTP/IP (close sockets), release network (e.g. `ConnectivityManager.bindProcessToNetwork(null)` or equivalent), optionally command camera to turn Wi‑Fi off via BLE if desired. Set `_connectionMode.value = ShootingConnectionMode.BLE_ONLY`. + +3. **Implement `triggerCapture()` when `connectionMode == FULL`** + Use PTP/IP to send `RequestOneShooting` or S1/S2 sequence (depending on shooting mode and preference). Do not use BLE FF01 in this branch. + +4. **Optional but recommended:** + Implement start/stop bulb, video record toggle, and other PTP/IP control codes in the delegate so the same UI can drive them when in FULL mode. Document which PTP/IP operations are implemented. + +5. **Tests:** + Unit tests for credential parsing, SSDP parsing, and PTP/IP init/probe can be done with mocked sockets or canned byte streams. Integration tests with a real camera or a mock TCP server that speaks PTP/IP will help validate the full path. + +--- + +## 5. Ricoh: Wi‑Fi Activation and HTTP Remote Shutter + +### 5.1 Reference Documentation + +- **`docs/ricoh/README.md`** — BLE UUIDs, dual-transport architecture, remote shutter (BLE write to `A3C51525`), Wi‑Fi handoff concept. +- **`docs/ricoh/WIFI_HANDOFF.md`** — Enabling camera WLAN via BLE (command characteristic `A3C51525`, WLAN control command model: networkType, wlanFreq), reading Wi‑Fi config from characteristic `5f0a7ba9` (SSID/password). Fallback to manual connection and `GET http://192.168.0.1/v1/props` to verify and cache credentials. +- **`docs/ricoh/HTTP_WEBSOCKET.md`** — HTTP API base `http://192.168.0.1`: `POST /v1/camera/shoot` to trigger shutter; capture status, shooting mode, drive mode, etc. Ricoh remote shutter is single-step (no half-press AF); BLE or HTTP can trigger it. + +### 5.2 BLE Wi‑Fi Activation (Ricoh) + +- **WLAN on:** Write to `A3C51525-DE3E-4777-A1C2-699E28736FCF` a WLAN control command (networkType `"wifi"`, wlanFreq 0 or 1 for 2.4/5 GHz). Serialize per Ricoh spec; wait for notification confirming WLAN enabled. Error: e.g. `step2_ble_wlan_on_failure`; WLAN cannot be enabled in Movie mode or when USB connected. +- **Credentials:** Read characteristic `5f0a7ba9-ae46-4645-abac-58ab2a1f4fe4` and parse SSID/password (format may require reverse engineering or docs). If read fails, support manual connection and then `GET http://192.168.0.1/v1/props` to verify and cache credentials. + +### 5.3 Wi‑Fi Association and HTTP (Ricoh) + +- Use the same Android pattern: `WifiNetworkSpecifier` + `requestNetwork()` + `bindProcessToNetwork(network)`. No SSDP; Ricoh cameras typically use a fixed IP (e.g. `192.168.0.1`) when in AP mode. Confirm default in docs or from device. +- **Verify connection:** `GET http://192.168.0.1/v1/props` (or similar) to confirm the app can reach the camera. Cache props (e.g. ssid, key) for future auto-connect if desired. + +### 5.4 Remote Shutter Over HTTP (Ricoh) + +- **Endpoint:** `POST /v1/camera/shoot` (see HTTP_WEBSOCKET.md). Single-shot trigger; no S1/S2. Optional: observe capture status via WebSocket or polling if the app needs to disable the shutter button during “capture”. +- **Bulb/Time:** For Bulb (B), Time (T), Bulb/Time (BT), the shutter button is typically hold-to-expose; document whether Ricoh HTTP supports a “start/stop” shoot or only a single trigger, and implement accordingly. + +### 5.5 Implementation Tasks (Ricoh) + +1. **Implement `RicohRemoteControlDelegate.connectWifi()`** + (1) Send BLE WLAN-on command to `A3C51525` and wait for confirmation, (2) Read `5f0a7ba9` for SSID/password (or use manual + GET /v1/props), (3) Connect Android to camera AP via `requestNetwork` + `bindProcessToNetwork`, (4) Verify with GET to camera base URL, (5) set `_connectionMode.value = ShootingConnectionMode.FULL`. + +2. **Implement `RicohRemoteControlDelegate.disconnectWifi()`** + Release network binding; optionally send BLE command to turn WLAN off. Set `_connectionMode.value = ShootingConnectionMode.BLE_ONLY`. + +3. **Implement `triggerCapture()` when `connectionMode == FULL`** + Use HTTP client (e.g. OkHttp or Ktor) to `POST http://192.168.0.1/v1/camera/shoot` (or the correct base URL from props). Handle timeouts and HTTP errors. + +4. **Optional:** + Use WebSocket or polling for capture status so the UI can show “capturing” vs “idle”. Document where this is used (e.g. disable shutter button during capture). + +5. **Tests:** + Unit tests for BLE command encoding and credential parsing; use a mock HTTP server for POST /v1/camera/shoot in tests. + +--- + +## 6. Delegate and UI Consistency + +- **Connection mode source of truth:** Each delegate holds `_connectionMode: MutableStateFlow`. BLE-only starts as default; after successful `connectWifi()` set to FULL; after `disconnectWifi()` set to BLE_ONLY. The UI already collects `delegate.connectionMode` and shows the correct banner and sections. +- **triggerCapture():** In both Sony and Ricoh delegates, the existing `if (connectionMode == FULL)` branch must be implemented (Sony: PTP/IP; Ricoh: HTTP). BLE branch is already implemented. +- **Wi‑Fi–only methods:** `touchAF`, `observeLiveView()`, `toggleAELock()`, etc., already have stubs or early returns when `connectionMode != FULL`. Once PTP/IP (Sony) or HTTP/WebSocket (Ricoh) is available, implement those that the vendor supports and that the UI exposes based on capabilities. +- **Error handling:** If `connectWifi()` fails (timeout, auth failure, no network), surface a clear message (and optionally retry). Do not set mode to FULL. Consider storing “last failure” for debugging or user guidance. + +--- + +## 7. Testing and Validation + +- **Unit tests:** Credential parsing (Sony CC06/CC07, Ricoh 5f0a7ba9), SSDP parsing (Sony), PTP/IP init/probe (Sony with mocked sockets), HTTP shoot (Ricoh with mock server). Use injected dispatchers/scopes where async or timeouts are involved. +- **Integration / device tests:** With a real Sony and Ricoh camera: BLE connection → Connect Wi‑Fi → verify FULL mode → trigger capture over Wi‑Fi → disconnect Wi‑Fi → verify BLE-only again. Document any model-specific quirks (e.g. ILCE-7M4 vs ZV-E10). +- **UI:** Re-enable the Connect Wi‑Fi button and run through the flow; confirm banner, capture button, and optional live view/advanced panels behave when switching between BLE-only and FULL. + +--- + +## 8. Out of Scope for “Wi‑Fi Remote Shutter” (Can Be Separate Follow-Ups) + +- **Live view stream:** Sony PTP/IP `SetLiveViewEnable` and frame handling; Ricoh WebSocket or HTTP for preview if available. Requires decoding and rendering pipeline. +- **Image browsing and transfer:** Sony PTP/IP GetContentsInfoList and object get; Ricoh HTTP photo listing and download. Covered by capability model but not required for “remote shutter” per se. +- **Touch AF, AEL, FEL, etc.:** Implement when FULL mode and PTP/IP (or Ricoh equivalent) are in place; delegate stubs already exist. +- **Firmware OTA over Wi‑Fi:** Separate feature; see docs (e.g. Ricoh CLOUD_SERVICES, firmware upload flows). + +--- + +## 9. Summary Checklist (Remaining Work) + +- [ ] **Shared:** Document/implement app-only network binding (requestNetwork + bindProcessToNetwork), permissions, and optional credential caching. +- [ ] **UI:** Re-enable “Connect Wi‑Fi for Full Features” in `ConnectionBanner`; keep “Disconnect Wi‑Fi” when in FULL. +- [ ] **Sony:** Implement `connectWifi()` (CC08 → CC06/CC07/CC0C → Wi‑Fi → SSDP → PTP/IP init + probe). +- [ ] **Sony:** Implement `disconnectWifi()` (tear down PTP/IP and network). +- [ ] **Sony:** Implement `triggerCapture()` in FULL mode via PTP/IP (RequestOneShooting or S1/S2). +- [ ] **Ricoh:** Implement `connectWifi()` (WLAN-on BLE → read credentials → Wi‑Fi → verify HTTP). +- [ ] **Ricoh:** Implement `disconnectWifi()` (release network, optional WLAN off). +- [ ] **Ricoh:** Implement `triggerCapture()` in FULL mode via `POST /v1/camera/shoot`. +- [ ] **Tests:** Unit tests for parsing and mocked PTP/IP/HTTP; device/integration tests for full path. +- [ ] **Docs:** Update AGENTS.md or README if Wi‑Fi becomes a supported user-facing feature; document any new permissions or OEM quirks. + +This plan is intended to be self-contained so that Wi‑Fi–based remote shutter can be completed later without the original design plan file. diff --git a/docs/ricoh/BATTERY.md b/docs/ricoh/BATTERY.md index 4f249e5..cebb942 100644 --- a/docs/ricoh/BATTERY.md +++ b/docs/ricoh/BATTERY.md @@ -3,8 +3,8 @@ Battery level is read and notified over BLE. Sufficient battery is required for firmware updates and can affect file transfer and connection steps. -**BLE Characteristic:** `FE3A32F8-A189-42DE-A391-BC81AE4DAA76` (Battery/Info) — Read and Notify. -**Model:** `BatteryLevelModel(level: int)` — 0–100 percentage. +**BLE Characteristic:** `875FC41D-4980-434C-A653-FD4A4D4410C4` — Read and Notify. +**Model:** `BatteryLevelModel(level: int, used: int)` — 0–100 percentage, plus power source (`0` = battery, `1` = AC adapter). --- diff --git a/docs/ricoh/DATA_MODELS.md b/docs/ricoh/DATA_MODELS.md index df65936..b973b56 100644 --- a/docs/ricoh/DATA_MODELS.md +++ b/docs/ricoh/DATA_MODELS.md @@ -57,17 +57,11 @@ StorageInformationModel( **DriveModeModel:** `DriveModeModel(driveMode: DriveMode)` -**DriveMode BLE enum (`QOa`)** — 16 values: - -| Value | Name | -|:------|:--------------------------------------| -| 0 | `oneFrame` | -| 1 | `tenSecondFrame` | -| 2 | `twoSecondFrame` | -| 3 | `continuousShootingFrame` | -| 4 | `bracketFrame` | -| 5–9 | bracket/multi-exposure + timer variants | -| 10–15 | interval / interval composition + timer variants | +**DriveMode BLE enum** (Drive Mode characteristic `B29E6DE3`) — **0–65 values**: + +The dm-zharov list expands the drive mode enum to include remote/self-timer variants, focus +bracketing, motion bracketing, mirror lockup, and star stream modes. Use the dm-zharov Drive Mode +table as the canonical mapping. **CountdownStatus enum (`uOa`):** `notInCountdown` (0), `selfTimerCountdown` (1), `scheduledTimeWaiting` (2) diff --git a/docs/ricoh/EXTERNAL_REFERENCES.md b/docs/ricoh/EXTERNAL_REFERENCES.md new file mode 100644 index 0000000..e195a86 --- /dev/null +++ b/docs/ricoh/EXTERNAL_REFERENCES.md @@ -0,0 +1,83 @@ +# External Ricoh Wireless References + +Community reverse‑engineered specs and tools. Use to cross-check BLE/Wi‑Fi behavior and fill gaps in the in-repo docs. + +--- + +## Primary BLE / Protocol Specs + +### [dm-zharov/ricoh-gr-bluetooth-api](https://github.com/dm-zharov/ricoh-gr-bluetooth-api) + +**RICOH GR Bluetooth API** — Unofficial list of GATT characteristics and values (GR II/III/IIIx, G900 SE, WG-M2, PENTAX K1/K-3/K-70/KF/KP). Sourced from reverse engineering and [RICOH THETA API](https://github.com/ricohapi/theta-api-specs). **Use at your own risk.** + +- [Characteristics list](https://github.com/dm-zharov/ricoh-gr-bluetooth-api/blob/main/characteristics_list.md) — full table of services/characteristics and R/W/Notify. +- **Shooting → Operation Request** + - Service: `9F00F387-8345-4BBC-8B92-B87B52E3091A` + - Characteristic: `559644B8-E0BC-4011-929B-5CF9199851E7` + - Format: 2 bytes `[OperationCode, Parameter]` + - **OperationCode:** `0` = NOP, `1` = Start Shooting/Recording, `2` = Stop Shooting/Recording + - **Parameter:** `0` = No AF, `1` = AF, `2` = Green Button Function + - Example: Start with AF → write `[0x01, 0x01]`; Stop → `[0x02, 0x00]`. +- **Camera → Camera Power** + - Service: `4B445988-CAA0-4DD3-941D-37B4F52ACA86` + - Characteristic: `B58CE84C-0666-4DE9-BEC8-2D27B27B3211` + - Value: `0` = Off, `1` = On, `2` = Sleep. +- **WLAN Control → Network Type** + - Service: `F37F568F-9071-445D-A938-5441F2E82399` + - Characteristic: `9111CDD0-9F01-45C4-A2D4-E09E8FB0424D` + - Value: `0` = OFF, `1` = AP mode. +- **WLAN Control → SSID** + - Same service; Characteristic: `90638E5A-E77D-409D-B550-78F7E1CA5AB4` (utf8s). +- **Bluetooth Control → BLE Enable Condition** + - Service: `0F291746-0C80-4726-87A7-3C501FD3B4B6` + - Characteristic: `D8676C92-DC4E-4D9E-ACCE-B9E251DDCC0C` + - Value: `0` = Disable, `1` = On anytime, `2` = On when power is on. +- **Shooting → Capture Mode** + - Service: `9F00F387-8345-4BBC-8B92-B87B52E3091A` + - Characteristic: `78009238-AC3D-4370-9B6F-C9CE2F4E3CA8` + - Value: `0` = Still, `2` = Movie. + +**Relation to this app:** We follow the dm-zharov spec for remote shutter: we use **Operation Request** +`559644B8` (Shooting service `9F00F387`) with 2-byte `[OperationCode, Parameter]` (Start=1, Stop=2; +AF=1, No AF=0). **Drive Mode** is `B29E6DE3`; **Shooting Mode** is `A3C51525`. For WLAN on/off, use +**Network Type** `9111CDD0` (service `F37F568F`), value 1=AP. Camera power off: **Camera Power** +`B58CE84C` (Camera service), value 0. HCI snoop is only needed if device testing shows issues; +iterate then. + +--- + +## Wireless Protocol & Wi‑Fi + +### [CursedHardware/ricoh-wireless-protocol](https://github.com/CursedHardware/ricoh-wireless-protocol) + +**RICOH Camera Wireless Protocol** — Reverse engineered from Image Sync 2.1.17. Wi‑Fi control plane documented (OpenAPI 3.0.3 in `openapi.yaml`); Bluetooth control plane in progress. Contains `definitions` (from app `res/raw`) and scripts. +Notable OpenAPI endpoints: `/v1/photos`, `/v1/photos/{dir}/{file}`, `/v1/photos/{dir}/{file}/info`, +`/v1/transfers`, `/v1/props`, `/v1/ping`, `/v1/liveview`, `/v1/device/finish`, `/v1/device/wlan/finish`. + +### [jkbrzt/grfs](https://github.com/jkbrzt/grfs) + +FUSE filesystem for **Ricoh GR II over Wi‑Fi** (read-only). Uses the camera’s HTTP API; no BLE. Useful for understanding the Wi‑Fi/HTTP side. + +### [clyang/GRsync](https://github.com/clyang/GRsync) + +**Sync photos from GR II / GR III via Wi‑Fi** (Python). Connects to camera AP and downloads images; supports GR IIIx. Complements [HTTP_WEBSOCKET.md](HTTP_WEBSOCKET.md) and [WIFI_HANDOFF.md](WIFI_HANDOFF.md). + +--- + +## Tools & Firmware + +### [yeahnope/gr_unpack](https://github.com/yeahnope/gr_unpack) + +**Ricoh GR III firmware unpacker** — Documents the packing format of GR III firmware images (header, frames, compression). No direct protocol impact; useful for low-level analysis. + +### [adriantache/GReat-Image-Downloader](https://github.com/adriantache/GReat-Image-Downloader) + +Android app that connects to **GR III / IIIx** over Wi‑Fi to download images (Kotlin, Clean Architecture). Good reference for Wi‑Fi flow and UX; no BLE remote control. + +--- + +## How we use this + +- **BLE command bytes / UUIDs:** Implementation is aligned with dm-zharov (Operation Request for shutter, Camera Power, Network Type). Use snoop only to verify or debug if behaviour differs on device. +- **Wi‑Fi handoff:** Align [WIFI_HANDOFF.md](WIFI_HANDOFF.md) with CursedHardware’s OpenAPI and GRsync/grfs HTTP usage where relevant. +- **Firmware / definitions:** Use CursedHardware definitions and gr_unpack only when digging into firmware or app resource formats. diff --git a/docs/ricoh/FIRMWARE_UPDATES.md b/docs/ricoh/FIRMWARE_UPDATES.md index 0db287f..0ea31c7 100644 --- a/docs/ricoh/FIRMWARE_UPDATES.md +++ b/docs/ricoh/FIRMWARE_UPDATES.md @@ -63,4 +63,5 @@ Only **GR IV** (`gr4`) currently supports in-app firmware update. - Phone storage space available - Camera not in shooting mode -BLE firmware-update cancel characteristics: see [README.md](README.md) §2.1.7 (`B29E6DE3`, `0936b04c`). Notification type `bOa` for firmware update result. +BLE firmware update **cancel** characteristics are not documented in the public UUID lists. +Cancel is reliably supported via HTTP `GET /v1/configs/firmware/cancel`. diff --git a/docs/ricoh/GPS_LOCATION.md b/docs/ricoh/GPS_LOCATION.md index ca1b305..bc4b483 100644 --- a/docs/ricoh/GPS_LOCATION.md +++ b/docs/ricoh/GPS_LOCATION.md @@ -61,25 +61,28 @@ The following GPS fields are written to image EXIF (IFD GPS): **BLE Service:** `84A0DD62-E8AA-4D0F-91DB-819B6724C69E` (GeoTag Write Service) **BLE Characteristic:** `28F59D60-8B8E-4FCD-A81F-61BDB46595A9` (GeoTag Write) -**Write Data Format:** +**Write Data Format (from dm-zharov):** -The data is written as a serialized byte array containing: +The payload is a fixed binary structure: ```dart GeoTagWriteData( - latitude: double, // GPS latitude in degrees - longitude: double, // GPS longitude in degrees - altitude: double, // Altitude in meters - year: int, // UTC year - month: int, // UTC month (1-12) - day: int, // UTC day (1-31) - hours: int, // UTC hours (0-23) - minutes: int, // UTC minutes (0-59) - seconds: int, // UTC seconds (0-59) - datum: String // Geodetic datum (e.g., "WGS-84") + latitude: float64, // BIG_ENDIAN + longitude: float64, // BIG_ENDIAN + altitude: float64, // BIG_ENDIAN + year: int16, // LITTLE_ENDIAN + month: int8, + day: int8, + hours: int8, + minutes: int8, + seconds: int8, + datum: int8 // Always 0 = WGS84 ) ``` +**Endianness:** All fields are BIG_ENDIAN **except** `year`, which is LITTLE_ENDIAN. +The timestamp is the time the location was acquired (UTC), not necessarily the current wall clock time. + **Write configuration:** Uses `writeCharacteristic` with `write_type` and `allow_long_write` parameters (the payload exceeds the default BLE MTU). diff --git a/docs/ricoh/HTTP_WEBSOCKET.md b/docs/ricoh/HTTP_WEBSOCKET.md index 6aba44d..446a7dd 100644 --- a/docs/ricoh/HTTP_WEBSOCKET.md +++ b/docs/ricoh/HTTP_WEBSOCKET.md @@ -13,12 +13,23 @@ and firmware operations. This document describes the endpoints, WebSocket status ### 5.1.1. Camera Information & Control -| Method | Endpoint | Purpose | Timeout | -|:-------|:--------------------|:--------------------------------------------------|:--------------------------| -| `GET` | `/v1/props` | Camera properties (model, serial, SSID, firmware) | 3s (verify), 10s (normal) | -| `GET` | `/v1/status/device` | Device status (power state, etc.) | 10s | -| `POST` | `/v1/device/finish` | End session/disconnect (empty body) | 10s | -| `PUT` | `/v1/params/device` | Set device parameters (WiFi config) | 10s | +| Method | Endpoint | Purpose | Timeout | +|:-------|:-------------------------|:--------------------------------------------------|:--------------------------| +| `GET` | `/v1/props` | Camera properties (model, serial, SSID, firmware) | 3s (verify), 10s (normal) | +| `GET` | `/v1/ping` | Ping device (returns camera time) | 3s | +| `GET` | `/v1/liveview` | Live view MJPEG stream (if supported) | 10s | +| `GET` | `/v1/constants/device` | Legacy device info (GR II era) | 10s | +| `GET` | `/_gr/objs` | Legacy object list (GR Remote/GR II) | 10s | +| `POST` | `/v1/device/finish` | End session / shutdown camera | 10s | +| `POST` | `/v1/device/wlan/finish` | Shutdown WLAN only | 10s | +| `PUT` | `/v1/params/device` | Set device parameters (e.g., stillFormat) | 10s | +| `PUT` | `/v1/params/camera` | Set capture parameters | 10s | +| `PUT` | `/v1/params/camera/compAdjust` | Composition adjustment | 10s | +| `PUT` | `/v1/params/lens` | Set lens parameters (e.g., focusSetting) | 10s | +| `POST` | `/v1/lens/focus` | Focus at point | 10s | +| `POST` | `/v1/lens/focus/lock` | Lock focus | 10s | +| `POST` | `/v1/lens/focus/unlock` | Unlock focus | 10s | +| `POST` | `/v1/params/lens/zoom` | Set zoom level | 10s | **`/v1/props` Response:** @@ -44,116 +55,80 @@ and firmware operations. This document describes the endpoints, WebSocket status | `bdName` | string | Bluetooth device name | | `firmwareVersion` | string | Firmware version (e.g., "1.50") | -**`/v1/params/device` Body (WiFi Configuration):** +**`/v1/params/device` Body (Device Parameters):** -``` -Content-Type: application/json - -channel=0&ssid=RICOH_1234&key=password123 -``` +OpenAPI defines this as a form body with fields like `stillFormat`. It is **not** the Wi-Fi +configuration endpoint; Wi-Fi credentials are managed via BLE or `/v1/props`. ### 5.1.2. Photo Operations -| Method | Endpoint | Purpose | Timeout | -|:-------|:-------------------------------------|:----------------------------|:--------| -| `GET` | `/v1/photos/infos?storage=in&after=` | List photos with pagination | 10s | -| `GET` | `/v1/photos/?size=thumb` | Get thumbnail | 15s | -| `GET` | `/v1/photos/?size=view` | Get preview/view size | 15s | -| `GET` | `/v1/photos/?size=xs` | Get extra-small preview | 15s | -| `GET` | `/v1/photos/?storage=in` | Download full image | 120s | -| `GET` | `/v1/photos//info?storage=in` | Get single photo info | 10s | -| `PUT` | `/v1/photos//transfer` | Prepare file for transfer | 10s | -| `GET` | `/v1/transfers?status=&storage=` | List transfer queue status | 10s | +| Method | Endpoint | Purpose | Timeout | +|:-------|:--------------------------------------------------|:----------------------------|:--------| +| `GET` | `/v1/photos?storage=in&limit=&after=` | List photos with pagination | 10s | +| `GET` | `/v1/photos/{dir}/{file}?size=thumb` | Get thumbnail | 15s | +| `GET` | `/v1/photos/{dir}/{file}?size=view` | Get preview/view size | 15s | +| `GET` | `/v1/photos/{dir}/{file}?size=xs` | Get extra-small preview | 15s | +| `GET` | `/v1/photos/{dir}/{file}` | Download full image | 120s | +| `GET` | `/v1/photos/{dir}/{file}/info` | Get single photo info | 10s | +| `PUT` | `/v1/photos/{dir}/{file}/transfer` | Set transfer status | 10s | +| `GET` | `/v1/transfers?status=&storage=&limit=&after=` | List transfer queue status | 10s | **Storage Parameter Values:** - `storage=in` - Internal storage - `storage=sd1` - SD card slot 1 -**`/v1/photos/infos` Response:** +**`/v1/photos` Response (list only):** ```json { "errCode": 200, "dirs": [ - "100RICOH", - "101RICOH" - ], - "files": [ { - "memory": 0, - "dir": "100RICOH", - "file": "R0001234.DNG", - "size": 25165824, - "recorded_size": "24.0MB", - "datetime": 1704067200, - "recorded_time": "2024-01-01 12:00:00", - "orientation": 1, - "aspect_ratio": "3:2", - "av": "F2.8", - "tv": "1/250", - "sv": "ISO200", - "xv": "+0.3", - "lat_lng": "35.6762,139.6503", - "gps_info": "{...}" + "name": "100RICOH", + "files": ["R0001234.DNG", "R0001235.JPG"] } ] } ``` -| Field | Type | Description | -|:----------------|:-------|:---------------------------------------------| -| `memory` | int | Storage location (0 = internal, 1 = SD card) | -| `dir` | string | Directory name (e.g., "100RICOH") | -| `file` | string | Filename (e.g., "R0001234.DNG") | -| `size` | int | File size in bytes | -| `recorded_size` | string | Human-readable file size | -| `datetime` | int | Unix timestamp | -| `recorded_time` | string | Formatted datetime string | -| `orientation` | int | EXIF orientation (1-8) | -| `aspect_ratio` | string | "1:1", "3:2", "4:3", "16:9" | -| `av` | string | Aperture value (e.g., "F2.8") | -| `tv` | string | Shutter speed (e.g., "1/250") | -| `sv` | string | ISO sensitivity (e.g., "ISO200") | -| `xv` | string | Exposure compensation (e.g., "+0.3") | -| `lat_lng` | string | GPS coordinates "lat,lng" | -| `gps_info` | string | Full GPS info JSON | - -**Transfer Status Response (`/v1/transfers`):** +**`/v1/photos/{dir}/{file}/info` Response (metadata):** + +Returns fields like `cameraModel`, `orientation`, `aspectRatio`, `av`, `tv`, `sv`, `xv`, `size`, +`gpsInfo`, and `datetime` (see OpenAPI schema `PhotoMetadata`). + +**Transfer List Response (`/v1/transfers`):** ```json { "errCode": 200, "transfers": [ { + "index": 1, "filepath": "/100RICOH/R0001234.DNG", - "status": "transferred" + "size": 25165824 } ] } ``` -| Status | Meaning | -|:----------------|:-------------------------| -| `transferred` | File transfer complete | -| `untransferred` | File not yet transferred | +Filter by status using the `status` query parameter (`transferred` or `untransferred`). -**Transfer PUT Body (`/v1/photos//transfer`):** +**Transfer PUT Body (`/v1/photos/{dir}/{file}/transfer`):** -``` -Content-Type: application/json - -storage=in -``` +OpenAPI defines a form body with `status` and `storage` (e.g., `status=transferred&storage=sd1`). **Aspect Ratios:** `1:1`, `3:2`, `4:3`, `16:9` ### 5.1.3. Remote Shooting -| Method | Endpoint | Purpose | -|:-----------|:------------------------|:----------------------------| -| `POST` | `/v1/camera/shoot` | Trigger shutter | -| `GET/POST` | `/v1/photos?storage=in` | Photo capture/list endpoint | +| Method | Endpoint | Purpose | +|:------|:------------------------------|:------------------------| +| `POST` | `/v1/camera/shoot` | Trigger shutter | +| `POST` | `/v1/camera/shoot/start` | Start shoot (sequence) | +| `POST` | `/v1/camera/shoot/compose` | Compose shoot | +| `POST` | `/v1/camera/shoot/cancel` | Cancel shoot | +| `POST` | `/v1/camera/shoot/finish` | Finish shoot | ### 5.1.4. Image Control (Read) @@ -236,7 +211,7 @@ The `CaptureStatusModel` contains two sub-states that independently track countd | `capturing` | `CaptureCapturing` | `notInShootingProcess` (0) — idle; `inShootingProcess` (1) — capturing | > **Note on Remote Shutter:** The Ricoh protocol provides a single-step "shoot" command only (HTTP -> `POST /v1/camera/shoot` or BLE write to `A3C51525`). There is **no half-press/S1 autofocus step** +> `POST /v1/camera/shoot` or BLE write to **Operation Request** `559644B8`). There is **no half-press/S1 autofocus step** > — the camera handles AF internally upon trigger. There is also **no touch AF** or **focus status > reading** capability. AF control is Sony-only. @@ -252,19 +227,11 @@ The `CaptureStatusModel` contains two sub-states that independently track countd | `still` | Still image mode | | `movie` | Movie/video mode | -**Capture Type (`CaptureType`, BLE `3e0673e0`):** -| Value | BLE Index | Meaning | -|:--------|:----------|:-----------------| -| `image` | 0 | Still image mode | -| `video` | 1 (→2) | Video/movie mode | - -**Capture Mode Values (`CaptureMode`, BLE `009A8E70`):** +**Capture Mode (BLE `78009238`):** | Value | Meaning | | :--- | :--- | -| `single` | Single shot | -| `continuous` | Continuous shooting | -| `interval` | Interval timer | -| `multiExposure` | Multiple exposure | +| `0` | Still image mode | +| `2` | Movie/video mode | **Drive Mode Values (WebSocket, 6 base modes):** | Value | Asset | Description | @@ -276,30 +243,11 @@ The `CaptureStatusModel` contains two sub-states that independently track countd | `interval` | `drive_interval.png` | Interval timer | | `multi_exp_interval` | `drive_multi_exp_interval.png` | Multi-exp + interval | -**Drive Mode Enum (BLE `QOa`, 16 values via `A3C51525` notify):** - -The BLE drive mode notification on characteristic `A3C51525` provides a finer-grained 16-value enum -that combines drive mode + self-timer state. Each base drive mode has up to 3 variants (no timer, -10-second timer, 2-second timer): - -| BLE Value | Internal Name | Drive Mode | Timer | -|:----------|:---------------------------------------|:---------------------|:------| -| 0 | `oneFrame` | Single | Off | -| 1 | `tenSecondFrame` | Single | 10s | -| 2 | `twoSecondFrame` | Single | 2s | -| 3 | `continuousShootingFrame` | Continuous | Off | -| 4 | `bracketFrame` | Auto Bracket | Off | -| 5 | `bracketTenSecondFrame` | Auto Bracket | 10s | -| 6 | `bracketTwoSecondFrame` | Auto Bracket | 2s | -| 7 | `multipleExposureFrame` | Multi-exposure | Off | -| 8 | `multipleExposureTenSecondFrame` | Multi-exposure | 10s | -| 9 | `multipleExposureTwoSecondFrame` | Multi-exposure | 2s | -| 10 | `intervalFrame` | Interval | Off | -| 11 | `intervalTenSecondFrame` | Interval | 10s | -| 12 | `intervalTwoSecondFrame` | Interval | 2s | -| 13 | `intervalCompositionFrame` | Interval Composite | Off | -| 14 | `intervalCompositionTenSecondFrame` | Interval Composite | 10s | -| 15 | `intervalCompositionTwoSecondFrame` | Interval Composite | 2s | +**Drive Mode Enum (BLE `B29E6DE3`):** + +The BLE Drive Mode characteristic exposes a **0–65** enum that includes self-timer and remote +variants (see dm-zharov Drive Mode table). The WebSocket/UI still uses the 6 base drive modes +above. **Time/Bulb Shooting State (`TimeShootingState`):** @@ -351,9 +299,8 @@ The camera supports these exposure modes (shown in remote shutter UI): | Interval | `interval` | `drive_interval.png` | | Multi-exp Interval | `multi_exp_interval` | `drive_multi_exp_interval.png` | -> **See also:** The BLE drive mode notification on `A3C51525` provides a 16-value enum that -> combines drive mode + self-timer state. See §5.2 "Drive Mode Enum (BLE `QOa`)" for the full -> mapping. +> **See also:** The BLE **Drive Mode** characteristic is `B29E6DE3` and exposes a 0–65 enum with +> self-timer and remote variants. See dm-zharov for the full mapping. --- diff --git a/docs/ricoh/IMAGE_CONTROL.md b/docs/ricoh/IMAGE_CONTROL.md index e9e262a..5cdea30 100644 --- a/docs/ricoh/IMAGE_CONTROL.md +++ b/docs/ricoh/IMAGE_CONTROL.md @@ -81,7 +81,8 @@ These parameters can be adjusted for each preset: **GR IV Firmware Requirement:** Image Control setting requires GR IV firmware version 1.04 or later. -**BLE Service:** `9F00F387-8345-4BBC-8B92-B87B52E3091A` (Image Control Service). Error code: `ER_BL_004` on read failure. +**BLE Note:** Public specs map `9F00F387-8345-4BBC-8B92-B87B52E3091A` to the **Shooting Service**, not +Image Control. No public BLE characteristic has been identified for Image Control write operations. **Error Messages:** @@ -89,8 +90,7 @@ Image Control setting requires GR IV firmware version 1.04 or later. - "The Image Control cannot be set when the camera is in the movie mode." - "The camera does not support the Image Control setting feature." -> **Note:** The exact HTTP or BLE mechanism for the write operation could not be fully determined -> from static binary analysis. The binary data is passed internally as `customImageControlData` -> with a `customNum` (slot 1-3) parameter. The write likely uses the Wi-Fi HTTP connection since -> it requires an active data session, but the specific endpoint/characteristic is not exposed as -> a string literal in the binary. +> **Note:** The exact write mechanism is still not documented publicly. The official app passes +> `customImageControlData` with a `customNum` (slot 1-3) internally. Given the payload size and the +> need for an active data session, this likely uses the Wi-Fi HTTP connection, but the endpoint is +> not exposed as a string literal in the binary. diff --git a/docs/ricoh/README.md b/docs/ricoh/README.md index ac4d506..b4da672 100644 --- a/docs/ricoh/README.md +++ b/docs/ricoh/README.md @@ -33,107 +33,92 @@ traffic goes to the camera and not 4G/5G. ### 2.1. BLE UUIDs -The camera exposes **5 BLE services** (1 for discovery, 4 for post-connection operations), each -containing multiple characteristics. This is significantly more complex than a single-service design. +This section is aligned with the dm-zharov characteristic list and the Image Sync 2.1.17 +`definitions/def_ble.decrypted.yaml`. Some earlier UUID mappings were incorrect; use the tables below +as the authoritative mapping for GR III/IIIx. #### 2.1.1. Service UUIDs -| UUID | Name | Purpose | -|:---------------------------------------|:------------------------------|:---------------------------------------------------------------| -| `ef437870-180a-44ae-affd-99dbd85ade43` | Discovery Service | BLE scan filter — used only for discovering Ricoh cameras | -| `9A5ED1C5-74CC-4C50-B5B6-66A48E7CCFF1` | Main Camera Service | Primary post-connection service (`bleServiceProvider`) | -| `4B445988-CAA0-4DD3-941D-37B4F52ACA86` | Camera State Notification | Multiplexed notifications for 11 camera state types: CameraPower (`YNa`), BatteryLevel (`XNa`), GeoTag (`cOa`), StorageInformation (`eOa`), CaptureMode (`sPa`), CaptureStatus (`tPa`), ShootingMode (`uPa`), DriveMode (`ZNa`), FileTransferList (`aOa`), PowerOffDuringFileTransfer (`dOa`), FirmwareUpdateResult (`bOa`). Error: `ER_BL_006` "NotifyError" on subscription failure | -| `84A0DD62-E8AA-4D0F-91DB-819B6724C69E` | GeoTag Write Service | GPS coordinate transmission to camera | -| `9F00F387-8345-4BBC-8B92-B87B52E3091A` | Image Control Service | Reading/writing Image Control presets (custom1/2/3 slots). Error code: `ER_BL_004` on read failure | +| UUID | Name | Purpose | +|:---------------------------------------|:-----------------------------|:--------------------------------------------------| +| `0x180A` | Device Information Service | Standard GATT device info | +| `9A5ED1C5-74CC-4C50-B5B6-66A48E7CCFF1` | Camera Information Service | Ricoh-specific device info | +| `4B445988-CAA0-4DD3-941D-37B4F52ACA86` | Camera Service | Camera state, power, storage, date/time, etc. | +| `9F00F387-8345-4BBC-8B92-B87B52E3091A` | Shooting Service | Capture/shutter related settings and notifications | +| `84A0DD62-E8AA-4D0F-91DB-819B6724C69E` | GPS Control Command | GPS information write | +| `F37F568F-9071-445D-A938-5441F2E82399` | WLAN Control Command | Wi-Fi enable + SSID/passphrase/channel | +| `0F291746-0C80-4726-87A7-3C501FD3B4B6` | Bluetooth Control Command | BLE settings (enable condition, paired name) | -#### 2.1.2. Core Characteristics (Well-Understood) +#### 2.1.2. Notification Characteristics (Multiplexed) -| UUID | Name | Operations | Purpose | -|:---------------------------------------|:-----------------------|:----------------|:---------------------------------------------| -| `0F291746-0C80-4726-87A7-3C501FD3B4B6` | Handshake/Notify | Subscribe, Read | "Step 4" liveness check — critical handshake | -| `5f0a7ba9-ae46-4645-abac-58ab2a1f4fe4` | Wi-Fi Config | Read | SSID/Password credential exchange | -| `A3C51525-DE3E-4777-A1C2-699E28736FCF` | Drive Mode / Command | Write, Notify | Drive mode notifications (`QOa` enum, 16 values — see [HTTP_WEBSOCKET.md §5.2](HTTP_WEBSOCKET.md)); also used for WLAN on/off and remote shutter commands | -| `FE3A32F8-A189-42DE-A391-BC81AE4DAA76` | Battery/Info | Read, Notify | Battery level, camera info | -| `28F59D60-8B8E-4FCD-A81F-61BDB46595A9` | GeoTag Write | Write | GPS coordinate data written to camera | +| UUID | Service | Purpose | +|:---------------------------------------|:---------------|:---------------------------------------------------------------------| +| `FAA0AEAF-1654-4842-A139-F4E1C1E722AC` | Camera Service | Camera Service Notification — multiplexes camera state changes | +| `671466A5-5535-412E-AC4F-8B2F06AF2237` | Shooting | Shooting Service Notification — multiplexes shooting state changes | +| `2AC97991-A78B-4CD4-9AE8-6E030E1D9EDB` | Shooting | High Frequency Shooting Notification (same payload structure) | -#### 2.1.3. Camera Information Service Characteristics +#### 2.1.3. Camera Information Service Characteristics (Corrected) These 6 UUIDs map 1:1 to the fields of `CameraInformationServiceModel`: | UUID | Field | Purpose | |:---------------------------------------|:---------------------------|:---------------------------------| -| `B4EB8905-7411-40A6-A367-2834C2157EA7` | `manufacturerNameString` | Manufacturer name (e.g., "RICOH")| -| `97E34DA2-2E1A-405B-B80D-F8F0AA9CC51C` | `bluetoothDeviceName` | BLE name (e.g., "RICOH GR IIIx")| -| `35FE6272-6AA5-44D9-88E1-F09427F51A71` | `bluetoothMacAddressString`| Bluetooth MAC address | -| `F5666A48-6A74-40AE-A817-3C9B3EFB59A6` | `firmwareRevisionString` | Firmware version | -| `6FE9D605-3122-4FCE-A0AE-FD9BC08FF879` | `modelNumberString` | Model number | +| `F5666A48-6A74-40AE-A817-3C9B3EFB59A6` | `manufacturerNameString` | Manufacturer name (e.g., "RICOH")| +| `35FE6272-6AA5-44D9-88E1-F09427F51A71` | `modelNumberString` | Model number | | `0D2FC4D5-5CB3-4CDE-B519-445E599957D8` | `serialNumberString` | Serial number | - -#### 2.1.4. Camera Mode & Shooting Characteristics - -| UUID | Purpose | Enum/Type Values | -|:---------------------------------------|:------------------------------------------------|:-----------------------------------------------------| -| `BD6725FC-5D16-496A-A48A-F784594C8ECB` | Operation mode list (available modes) | `capture`, `playback`, `bleStartup`, `other`, `powerOffTransfer` | -| `D9AE1C06-447D-4DEA-8B7D-FC8B19C2CDAE` | Current operation mode | Same enum as above | -| `63BC8463-228F-4698-B30D-FAF8E3D7BD88` | User mode / Shooting mode / Drive mode | UserMode, ShootingMode (`still`/`movie`), DriveMode enums | -| `3e0673e0-1c7b-4f97-8ca6-5c2c8bc56680` | Capture type (still vs. video) | `image` (0), `video` (1→2) | -| `009A8E70-B306-4451-B943-7F54392EB971` | Capture mode | CaptureMode enum (`single`, `continuous`, `interval`, `multiExposure`) | -| `B5589C08-B5FD-46F5-BE7D-AB1B8C074CAA` | Exposure mode (primary) | `P`, `Av`, `Tv`, `M`, `B`, `BT`, `T`, `SFP` | -| `df77dd09-0a48-44aa-9664-2116d03b6fd7` | Exposure mode (companion) | Same enum as above | - -#### 2.1.5. WLAN Configuration Characteristics - -These 5 UUIDs appear together in the WLAN configuration builder block, directly after the -`WLANControlCommandModel(networkType, passphrase, wlanFreq)` definition. - -| UUID | Purpose | Details | -|:---------------------------------------|:------------------------------------------------|:-----------------------------------------------------| -| `460828AC-94EB-4EDF-9BB0-D31E75F2B165` | WLAN control command | Reads `WLANControlCommandModel` (networkType, passphrase, wlanFreq). Associated with `CLa` enum (`read`/`write`) | -| `C4B7DFC0-80FD-4223-B132-B7D25A59CF85` | WLAN passphrase or frequency | Individual WLAN config field — likely passphrase or network type (adjacent to WLAN control in builder) | -| `0F38279C-FE9E-461B-8596-81287E8C9A81` | WLAN passphrase or frequency | Individual WLAN config field — the remaining field not covered by C4B7DFC0 | -| `9111CDD0-9F01-45C4-A2D4-E09E8FB0424D` | WLAN security type (primary) | `TPa` enum: `wpa2` (0), `wpa3` (1), `transition` (2) | -| `90638E5A-E77D-409D-B550-78F7E1CA5AB4` | WLAN security type (companion) | Same `TPa` enum. Paired with `9111CDD0` — likely one is read, one is notify | - -#### 2.1.6. Camera Service Model Characteristics - -These characteristics populate the `CameraServiceModel` which has 9 fields: `cameraPower`, -`operationModeList`, `operationMode`, `geoTag`, `storageInformation`, `fileTransferList`, -`powerOffDuringTransfer`, `gradNd`, `cameraName`. - -The builder registers 13 UUIDs total. Two are known service-level UUIDs (`5f0a7ba9` Wi-Fi Config, -`ef437870` Discovery) referenced for cross-service data. The remaining 11 map to the 9 fields: - -| UUID | Field / Purpose | Confidence | -|:---------------------------------------|:------------------------------------------------|:-----------| -| `A0C10148-8865-4470-9631-8F36D79A41A5` | **fileTransferList** — `FileTransferListModel(isNotEmpty)` | High (immediately follows `FileTransferListModel` definition in pool) | -| `BD6725FC-5D16-496A-A48A-F784594C8ECB` | **operationModeList** — Available operation modes | High (`YLa` type confirmed: `capture`, `playback`, `bleStartup`, `other`, `powerOffTransfer`) | -| `D9AE1C06-447D-4DEA-8B7D-FC8B19C2CDAE` | **operationMode** — Current operation mode | High (paired with `BD6725FC`, same `YLa` enum) | -| `A36AFDCF-6B67-4046-9BE7-28FB67DBC071` | One of: cameraPower, geoTag, storageInformation, gradNd, cameraName | Medium (single UUID, no associated type in pool — likely a simple value field) | -| `e450ed9b-dd61-43f2-bdfb-6af500c910c3` | One of: cameraPower, geoTag, storageInformation, gradNd, cameraName | Medium (same pattern as `A36AFDCF`) | -| `B58CE84C-0666-4DE9-BEC8-2D27B27B3211` | One of: cameraPower, geoTag, storageInformation, gradNd, cameraName | Medium (same pattern as above) | -| `1452335A-EC7F-4877-B8AB-0F72E18BB295` | Likely **powerOffDuringTransfer** or **storageInformation** (primary) | Medium (paired with `875FC41D` — the dual-field nature fits `PowerOffDuringFileTransferModel(behavior, autoResize)`) | -| `875FC41D-4980-434C-A653-FD4A4D4410C4` | Likely **powerOffDuringTransfer** or **storageInformation** (companion) | Medium (paired with `1452335A`) | -| `FA46BBDD-8A8F-4796-8CF3-AA58949B130A` | CameraServiceModel construction — possibly the camera state service UUID | Medium (trio terminates with `_cMa` CameraServiceModel type) | -| `430B80A3-CC2E-4EC2-AACD-08610281FF38` | CameraServiceModel construction — read or notify characteristic | Medium (part of trio) | -| `39879aac-0af6-44b5-afbb-ca50053fa575` | CameraServiceModel construction — the third in the trio | Medium (last UUID before `_cMa` type in pool) | - -#### 2.1.7. Other Characteristics - -These characteristics appear in the camera state/notification registration builder but do not -belong to a specific named service model: - -| UUID | Purpose | Confidence | -|:---------------------------------------|:------------------------------------------------|:-----------| -| `B29E6DE3-1AEC-48C1-9D05-02CEA57CE664` | **Firmware update cancel** (primary) — `xPa` enum with value `cancel` (0) | High | -| `0936b04c-7269-4cef-9f34-07217d40cc55` | **Firmware update cancel** (companion) — paired with `B29E6DE3` | High | -| `F37F568F-9071-445D-A938-5441F2E82399` | **Device Information bridge** — reads standard GATT short UUIDs (`2A26` Firmware Rev, `2A24` Model Number, `2A28` Software Rev, `2A29` Manufacturer, `2A25` Serial Number) | High | -| `e799198f-cf3f-4650-9373-b15dda1b618c` | **Storage information list** (primary) — `List` type, returns list of `StorageInformationModel` entries | High | -| `30adb439-1bc0-4b8e-9c8b-2bd1892ad6b0` | **Storage information list** (companion) — paired with `e799198f`, likely one read / one notify | High | -| `78009238-AC3D-4370-9B6F-C9CE2F4E3CA8` | **Camera power or GeoTag status** — positioned between capture type and storage list in builder. One of the remaining notification-readable states (cameraPower, geoTag, batteryLevel) | Medium | -| `559644B8-E0BC-4011-929B-5CF9199851E7` | **Camera power or battery level** (primary) — positioned between storage list and exposure mode. Paired with `cd879e7a` | Medium | -| `cd879e7a-ab9f-4c58-90ed-689bae67ef8e` | **Camera power or battery level** (companion) — paired with `559644B8` | Medium | - -#### 2.1.8. Standard BLE +| `B4EB8905-7411-40A6-A367-2834C2157EA7` | `firmwareRevisionString` | Firmware version | +| `6FE9D605-3122-4FCE-A0AE-FD9BC08FF879` | `bluetoothDeviceName` | BLE name (e.g., "RICOH GR IIIx") | +| `97E34DA2-2E1A-405B-B80D-F8F0AA9CC51C` | `bluetoothMacAddressString`| Bluetooth MAC address | + +#### 2.1.4. Camera Service Characteristics + +| UUID | Field / Purpose | +|:---------------------------------------|:----------------------------------------------| +| `B58CE84C-0666-4DE9-BEC8-2D27B27B3211` | Camera Power (0=off, 1=on, 2=sleep) | +| `875FC41D-4980-434C-A653-FD4A4D4410C4` | Battery Level + Power Source | +| `FA46BBDD-8A8F-4796-8CF3-AA58949B130A` | Date Time (read/write) | +| `1452335A-EC7F-4877-B8AB-0F72E18BB295` | Operation Mode (current) | +| `430B80A3-CC2E-4EC2-AACD-08610281FF38` | Operation Mode List | +| `A36AFDCF-6B67-4046-9BE7-28FB67DBC071` | GEO Tag enable | +| `A0C10148-8865-4470-9631-8F36D79A41A5` | Storage Information (list) | +| `D9AE1C06-447D-4DEA-8B7D-FC8B19C2CDAE` | File Transfer List | +| `BD6725FC-5D16-496A-A48A-F784594C8ECB` | Power Off During File Transfer (behavior/resize) | +| `209F9869-8540-460E-97A6-5C3AC08F2C73` | Grad ND | + +#### 2.1.5. Shooting Service Characteristics (Common) + +| UUID | Purpose | +|:---------------------------------------|:--------------------------------------------------| +| `A3C51525-DE3E-4777-A1C2-699E28736FCF` | Shooting Mode (P/Av/Tv/M/etc.) | +| `78009238-AC3D-4370-9B6F-C9CE2F4E3CA8` | Capture Mode (still=0, movie=2) | +| `B29E6DE3-1AEC-48C1-9D05-02CEA57CE664` | Drive Mode (0-65, includes timers/remote modes) | +| `F4B6C78C-7873-43F0-9748-F4406185224D` | Drive Mode List | +| `B5589C08-B5FD-46F5-BE7D-AB1B8C074CAA` | Capture Status (capturing + countdown state) | +| `559644B8-E0BC-4011-929B-5CF9199851E7` | Operation Request (remote shutter) | + +#### 2.1.6. WLAN Control Command Characteristics + +| UUID | Purpose | Details | +|:---------------------------------------|:-----------------------------------|:---------------------------| +| `9111CDD0-9F01-45C4-A2D4-E09E8FB0424D` | Network Type | 0=OFF, 1=AP mode | +| `90638E5A-E77D-409D-B550-78F7E1CA5AB4` | SSID | UTF-8 string | +| `0F38279C-FE9E-461B-8596-81287E8C9A81` | Passphrase | UTF-8 string | +| `51DE6EBC-0F22-4357-87E4-B1FA1D385AB8` | Channel | 0=Auto, 1-11=channels | + +#### 2.1.7. GPS Control Command Characteristics + +| UUID | Purpose | +|:---------------------------------------|:-----------------------------------| +| `28F59D60-8B8E-4FCD-A81F-61BDB46595A9` | GPS Information (write/read) | + +#### 2.1.8. Bluetooth Control Command Characteristics + +| UUID | Purpose | Details | +|:---------------------------------------|:-----------------------------------|:----------------------------------| +| `D8676C92-DC4E-4D9E-ACCE-B9E251DDCC0C` | BLE Enable Condition | 0=disable, 1=anytime, 2=power-on | +| `FE3A32F8-A189-42DE-A391-BC81AE4DAA76` | Paired Device Name | UTF-8 string | + +#### 2.1.9. Standard BLE **Descriptor:** @@ -210,7 +195,16 @@ This is the default state of the app. It scans, bonds, and waits. ### 3.1. Scanning **Library:** [Kable](https://github.com/JuulLabs/kable) `Scanner` -**Filter:** Service UUID `ef437870-180a-44ae-affd-99dbd85ade43` +**Filter:** Prefer **manufacturer data** (Company ID `0x065F`) over a service UUID. + +Image Sync advertises a manufacturer data payload with: +- **Model Code** (type `0x01`): `0x01` = GR III, `0x03` = GR IIIx +- **Serial Number** (type `0x02`) +- **Camera Power** (type `0x03`): `0x00` = OFF, `0x01` = ON + +Some devices also advertise a Ricoh-specific service UUID, but this is **not** documented in the +public characteristic lists. If you use a service UUID filter, treat it as a best-effort optimization +and fall back to manufacturer data matching. **Scan Result Model:** @@ -275,13 +269,19 @@ Don't just show a MAC address. Parse the `advertisement.name`. The official app logs mention specific step-based errors. This is the "liveness check". If this fails, the camera isn't actually listening. -**Procedure:** +**Procedure (Corrected to public UUIDs):** + +Public specs do **not** document a dedicated handshake characteristic. The official app’s "Step 4" +appears to be a liveness check that waits for **any** camera/shooting notification after bonding. + +1. **Subscribe** to **Camera Service Notification** `FAA0AEAF-1654-4842-A139-F4E1C1E722AC`. +2. **Subscribe** to **Shooting Service Notification** `671466A5-5535-412E-AC4F-8B2F06AF2237`. +3. **Wait** for the *first* notification from either channel. +4. **Timeout:** If nothing arrives in **5 seconds**, the connection is zombie. Disconnect and retry. +5. **Success:** The camera is now "Connected (Standby)". -1. **Subscribe** to Characteristic `0F291746-0C80-4726-87A7-3C501FD3B4B6`. -2. **Wait** for the *first* notification. -3. **Timeout:** If you don't hear back in **5 seconds**, the connection is zombie. Disconnect and - retry. -4. **Success:** The camera is now "Connected (Standby)". +**Important:** `0F291746-0C80-4726-87A7-3C501FD3B4B6` is the **Bluetooth Control Command service** (e.g., +BLE Enable Condition / Paired Device Name). It is **not** a notification channel. **Step-Based Error Codes (for diagnostics):** @@ -317,32 +317,35 @@ fails, the camera isn't actually listening. This section summarizes the key operations available over BLE. See §2 for full UUID tables. -**1. Notifications (Camera State):** -Subscribe to **Camera State Notification** service characteristics to receive real-time updates: -- **Battery Level:** (`FE3A32F8`) - 0-100% -- **Capture Status:** (`tPa`) - `CaptureStatusModel` with two sub-states: - - `countdown`: `notInCountdown` (0) / `selfTimerCountdown` (1) - - `capturing`: `notInShootingProcess` (0) / `inShootingProcess` (1) -- **Shooting Mode:** (`uPa`) - Still/Movie, Exposure Mode (P/Av/Tv/M) -- **Drive Mode:** (`A3C51525` notify, `QOa` enum) - 16-value enum combining drive mode + self-timer - (see [HTTP_WEBSOCKET.md §5.2](HTTP_WEBSOCKET.md) for full mapping) -- **Storage Info:** (`eOa`) - SD card status, remaining shots +**1. Notifications (Camera + Shooting State):** +Subscribe to the **Camera Service Notification** (`FAA0AEAF`) and **Shooting Service Notification** +(`671466A5`). Each notification includes a changed-value UUID; parse that UUID and decode using the +matching characteristic definition. + +Common notify/read characteristics: +- **Battery Level:** `875FC41D` (0-100, plus power source) +- **Capture Status:** `B5589C08` (capturing + countdown state) +- **Capture Mode:** `78009238` (still vs movie) +- **Shooting Mode:** `A3C51525` (P/Av/Tv/M/etc.) +- **Drive Mode:** `B29E6DE3` (full 0-65 enum) +- **Storage Info:** `A0C10148` (storage list) +- **File Transfer List:** `D9AE1C06` +- **Geo Tag Enable:** `A36AFDCF` **2. Commands (Write):** -- **Remote Shutter:** Trigger capture via Command characteristic (`A3C51525`). This is a - single-step fire command — no half-press/S1/AF step exists. The camera handles autofocus - internally. For Bulb/Time modes, first write starts exposure, second write stops it - (`TimeShootingState` tracks this). -- **WLAN Control:** Enable/Disable camera Wi-Fi (`A3C51525`) -- **Camera Power:** Turn camera off +- **Remote Shutter:** **Operation Request** `559644B8` (Shooting service). Payload: 2 bytes + `[OperationCode, Parameter]` — Start=1, Stop=2; Parameter: No AF=0, AF=1. +- **WLAN Control:** **Network Type** `9111CDD0` (service `F37F568F`), value 0=OFF, 1=AP. +- **Camera Power:** **Camera Power** `B58CE84C` (Camera service), value 0=Off. +- **Date/Time:** **Date Time** `FA46BBDD` (Camera service), write local time. **3. Information (Read):** - **Device Info:** Read standard GATT characteristics (Model, Serial, Firmware) - **Camera Info Service:** Read custom Ricoh info fields (§2.1.3) **4. Settings (Write):** -- **GPS Location:** Write coordinates to GeoTag service (§7) -- **Date/Time:** Sync phone time to camera (see Appendix B) +- **GPS Location:** Write **GPS Information** `28F59D60` (GeoTag service) — see [GPS_LOCATION.md](GPS_LOCATION.md). +- **Date/Time:** Sync phone time via **Date Time** `FA46BBDD`. --- @@ -409,7 +412,7 @@ See [ERROR_HANDLING.md](ERROR_HANDLING.md) for user-facing errors and recovery. ## Implementation Checklist (High-Level) -- **BLE:** Scan with service UUID filter; handle bonding; Step 4 handshake (5s timeout); battery/GPS/power; disconnect recovery. See [README](README.md) §3. +- **BLE:** Scan via manufacturer data (Company ID `0x065F`); handle bonding; Step 4 handshake (5s timeout); battery/GPS/power; disconnect recovery. See [README](README.md) §3. - **Wi-Fi:** [WIFI_HANDOFF.md](WIFI_HANDOFF.md) - **HTTP/WebSocket:** [HTTP_WEBSOCKET.md](HTTP_WEBSOCKET.md) (photos, remote shooting, logs) - **Firmware:** [FIRMWARE_UPDATES.md](FIRMWARE_UPDATES.md) @@ -450,7 +453,7 @@ GR protocol: | Capability | Status | Evidence | |:------------------------|:------------------------|:-------------------------------------------------------------------| -| **Live View stream** | Not supported | WebSocket is status-only JSON; no video stream endpoint exists; zero references to liveView/viewfinder in binary | +| **Live View stream** | Not confirmed | OpenAPI defines `/v1/liveview` (MJPEG), but GR III/IIIx behavior not confirmed in tests | | **Post-capture preview**| Not supported | No postView/postCapture references in binary | | **Half-press AF (S1)** | Not supported | Remote shutter is single-step "shoot" command; no focusing/AF state in CaptureStatusModel; no S1/half-press concept | | **Touch AF** | Not supported | No touch AF/touch operation references in binary | @@ -506,6 +509,7 @@ Users can set custom names for their cameras: | Document | Contents | |:---------|:---------| +| [EXTERNAL_REFERENCES.md](EXTERNAL_REFERENCES.md) | Community BLE/Wi‑Fi specs (dm-zharov, CursedHardware, GRsync, etc.) and how we use them | | [WIFI_HANDOFF.md](WIFI_HANDOFF.md) | Phase 2: Enabling WLAN, credentials, Android network binding | | [HTTP_WEBSOCKET.md](HTTP_WEBSOCKET.md) | HTTP API, WebSocket status, photo ops, remote shooting, exposure/drive/self-timer | | [IMAGE_CONTROL.md](IMAGE_CONTROL.md) | Image Control presets, parameters, applying to camera | diff --git a/docs/ricoh/WIFI_HANDOFF.md b/docs/ricoh/WIFI_HANDOFF.md index b916cd3..c0a545b 100644 --- a/docs/ricoh/WIFI_HANDOFF.md +++ b/docs/ricoh/WIFI_HANDOFF.md @@ -10,24 +10,15 @@ to the camera AP. ## 4.1. Enabling Camera WLAN (BLE Command) -Before connecting to Wi-Fi, you may need to turn on the camera's WLAN function via BLE: +Before connecting to Wi-Fi, turn on the camera's WLAN via the **WLAN Control Command** service +`F37F568F-9071-445D-A938-5441F2E82399`. -**Command Characteristic:** `A3C51525-DE3E-4777-A1C2-699E28736FCF` - -**WLAN Control Command Model:** - -```dart -WLANControlCommandModel( - networkType: String, // "wifi" - wlanFreq: int // 0=2.4GHz, 1=5GHz -) -``` +**Network Type Characteristic:** `9111CDD0-9F01-45C4-A2D4-E09E8FB0424D` **Write Operation:** -1. Serialize command to bytes -2. Write to characteristic -3. Wait for notification confirming WLAN enabled +1. Write `1` to enable AP mode (write `0` to disable). +2. Wait for the next camera/shooting notification to confirm state (or poll readback). **Error Cases:** @@ -44,17 +35,16 @@ WLANControlCommandModel( ## 4.2. Getting Credentials (BLE) -You need the SSID and Password. +You can read credentials directly from the WLAN Control Command service: -**Wi-Fi Config Characteristic:** `5f0a7ba9-ae46-4645-abac-58ab2a1f4fe4` +- **SSID:** `90638E5A-E77D-409D-B550-78F7E1CA5AB4` (UTF-8 string) +- **Passphrase:** `0F38279C-FE9E-461B-8596-81287E8C9A81` (UTF-8 string) +- **Channel:** `51DE6EBC-0F22-4357-87E4-B1FA1D385AB8` (0=Auto, 1-11) **Read Operation:** -1. Read characteristic value -2. Parse response for SSID and password - -**Response Format (presumed):** -The characteristic likely returns WiFi credentials in a structured format (binary or JSON). Reverse engineering (e.g., BLE sniffing) may be required to parse it. It likely contains the `ssid` and `password` fields similar to the HTTP `/v1/props` response. +1. Read SSID and Passphrase characteristics. +2. Optionally read channel (if you need a fixed channel hint). **Fallback Strategy (Manual Connection):** If the BLE credential read fails or is unimplemented: @@ -71,15 +61,14 @@ If the BLE credential read fails or is unimplemented: --- -## 4.3. WLAN Frequency Settings +## 4.3. WLAN Channel Settings -The camera supports different frequency bands: +The WLAN Control Command service exposes a **Channel** characteristic (`51DE6EBC`): -* **2.4 GHz** - Better compatibility, longer range -* **5 GHz** - Faster speeds, less interference +- `0` = Auto +- `1`–`11` = specific channel -The `wlan_frequency` parameter is stored in the device database and can be changed via camera -settings. +The camera still negotiates 2.4 GHz Wi-Fi; no public BLE characteristic exposes a 5 GHz toggle. --- @@ -115,6 +104,8 @@ The official app explicitly handles a "WPA3 Transition Mode" issue. 1. Try connection with `securityType` (WPA2 or WPA3). 2. If `onUnavailable` fires, **retry immediately** with the *other* security type. +**GR II note:** GR II only supports 20 MHz 802.11n; real-world throughput tops out around 65 Mbps. + --- ## 4.5. Verification diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f80f4e6..eef4fa7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ maplibre = "0.12.1" maplibre-spatialk = "0.6.1" mockk = "1.14.9" mockwebserver = "4.12.0" +robolectric = "4.12.2" metro = "0.10.2" navigation3 = "1.0.0" protobufPlugin = "0.9.6" @@ -63,6 +64,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } maplibre-core = { module = "org.maplibre.compose:maplibre-compose-android", version.ref = "maplibre" } maplibre-material3 = { module = "org.maplibre.compose:maplibre-compose-material3-android", version.ref = "maplibre" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a25f18f..ea22925 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,4 +24,5 @@ dependencyResolutionManagement { rootProject.name = "CameraSync" include(":app") - \ No newline at end of file +include(":detekt-rules") +