Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,39 @@ android {
}

buildFeatures { compose = true }
testOptions { unitTests.isIncludeAndroidResources = true }
installation { installOptions += listOf("--user", "0") }
}

ktfmt { kotlinLangStyle() }

tasks.withType<Test>().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 {
Expand Down Expand Up @@ -126,13 +154,18 @@ 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)
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(platform(libs.androidx.compose.bom))

detektPlugins(libs.compose.rules.detekt)
detektPlugins(project(":detekt-rules"))
}

// Setup protobuf configuration, generating lite Java and Kotlin classes
Expand Down
1 change: 0 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@
android:theme="@style/Theme.CameraSync"
tools:replace="android:appComponentFactory"
tools:targetApi="34">

<!-- False lint positive, it's ctor-injected by Metro -->
<service
android:name=".devicesync.MultiDeviceSyncService"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.juul.khronicle.Log
import com.juul.khronicle.Logger
import dev.sebastiano.camerasync.devicesync.registerNotificationChannel
import dev.sebastiano.camerasync.di.AppGraph
import dev.sebastiano.camerasync.di.MetroAppComponentFactory
import dev.sebastiano.camerasync.firmware.FirmwareUpdateCheckWorkerFactory
import dev.sebastiano.camerasync.firmware.FirmwareUpdateScheduler
import dev.sebastiano.camerasync.widget.SyncWidgetReceiver
Expand All @@ -22,10 +23,7 @@ import kotlinx.coroutines.launch

/** Application-level configuration and dependency creation for CameraSync. */
class CameraSyncApp : Application(), Provider {
/**
* Holder reference for the app graph for
* [dev.sebastiano.camerasync.di.MetroAppComponentFactory].
*/
/** Holder reference for the app graph for [MetroAppComponentFactory]. */
val appGraph by lazy { createGraphFactory<AppGraph.Factory>().create(application = this) }

private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/kotlin/dev/sebastiano/camerasync/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
},
)
}

Expand Down Expand Up @@ -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)
},
)
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/kotlin/dev/sebastiano/camerasync/NavRoute.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<android.bluetooth.le.ScanFilter>()

// 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) {
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
)
Expand All @@ -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"
)
Expand All @@ -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"
)
Expand All @@ -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"
)
Expand All @@ -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"
)
Expand All @@ -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"
)
Expand Down Expand Up @@ -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"
)
Expand All @@ -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"
)
Expand All @@ -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"
}
Expand Down
Loading