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 0000000..9a6cb67 Binary files /dev/null and b/camerasync.af differ diff --git a/detekt-rules/build.gradle.kts b/detekt-rules/build.gradle.kts new file mode 100644 index 0000000..85b35f6 --- /dev/null +++ b/detekt-rules/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + kotlin("jvm") +} + +val detektVersion = libs.versions.detekt.get() + +dependencies { + compileOnly("io.gitlab.arturbosch.detekt:detekt-api:$detektVersion") + testImplementation("io.gitlab.arturbosch.detekt:detekt-test:$detektVersion") + testImplementation(kotlin("test")) +} + +kotlin { + jvmToolchain(11) +} diff --git a/detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/CameraSyncRuleSetProvider.kt b/detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/CameraSyncRuleSetProvider.kt new file mode 100644 index 0000000..6da2dee --- /dev/null +++ b/detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/CameraSyncRuleSetProvider.kt @@ -0,0 +1,12 @@ +package dev.sebastiano.camerasync.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +class CameraSyncRuleSetProvider : RuleSetProvider { + override val ruleSetId: String = "camerasync" + + override fun instance(config: Config): RuleSet = + RuleSet(ruleSetId, listOf(NoFullyQualifiedAppReferenceRule(config))) +} diff --git a/detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRule.kt b/detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRule.kt new file mode 100644 index 0000000..73d3b01 --- /dev/null +++ b/detekt-rules/src/main/kotlin/dev/sebastiano/camerasync/detekt/NoFullyQualifiedAppReferenceRule.kt @@ -0,0 +1,91 @@ +package dev.sebastiano.camerasync.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtImportDirective +import org.jetbrains.kotlin.psi.KtPackageDirective +import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType + +class NoFullyQualifiedAppReferenceRule(config: Config) : Rule(config) { + override val issue: Issue = + Issue( + id = "NoFullyQualifiedAppReference", + severity = Severity.Style, + description = "Use imports instead of fully qualified app references.", + debt = io.gitlab.arturbosch.detekt.api.Debt.FIVE_MINS, + ) + + private val packagePrefix: String = "dev.sebastiano.camerasync." + + override fun visitDotQualifiedExpression(expression: KtDotQualifiedExpression) { + if (expression.getParentOfType(true) != 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") +