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
14 changes: 14 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ android {
}

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

Expand Down Expand Up @@ -123,9 +124,15 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)

testImplementation(libs.junit)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.junit)
testImplementation(libs.robolectric)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.mockwebserver)
testImplementation(libs.androidx.ui.test)
testImplementation(libs.androidx.ui.test.junit4)
testImplementation(libs.androidx.ui.test.manifest)

androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
Expand All @@ -135,6 +142,13 @@ dependencies {
detektPlugins(libs.compose.rules.detekt)
}

tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
systemProperty(
"robolectric.manifest",
"${project.projectDir}/src/test/resources/AndroidManifest.xml",
)
}

// Setup protobuf configuration, generating lite Java and Kotlin classes
protobuf {
protoc { artifact = libs.protobuf.protoc.get().toString() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,36 @@ internal class KableCameraConnection(
return revision
}

override suspend fun readModelName(): String {
if (!capabilities.supportsModelName) {
throw UnsupportedOperationException(
"${camera.vendor.vendorName} cameras do not support model name reading"
)
}

val serviceUuid = gattSpec.modelNameServiceUuid
val charUuid = gattSpec.modelNameCharacteristicUuid

if (serviceUuid == null || charUuid == null) {
throw UnsupportedOperationException(
"${camera.vendor.vendorName} cameras claims to support model name reading but UUIDs are not configured"
)
}

val service =
peripheral.services.value.orEmpty().firstOrNull { it.serviceUuid == serviceUuid }
?: error("Model name service not found: $serviceUuid")

val char =
service.characteristics.firstOrNull { it.characteristicUuid == charUuid }
?: error("Model name characteristic not found: $charUuid")

val bytes = peripheral.read(char)
val model = bytes.decodeToString().trimEnd(Char(0))
Log.info(tag = TAG) { "Model name: $model" }
return model
}

override suspend fun setPairedDeviceName(name: String) {
if (!capabilities.supportsDeviceName) {
throw UnsupportedOperationException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.lifecycle.viewModelScope
import com.juul.khronicle.Log
import dev.sebastiano.camerasync.R
import dev.sebastiano.camerasync.devicesync.MultiDeviceSyncService
import dev.sebastiano.camerasync.di.IoDispatcher
import dev.sebastiano.camerasync.domain.model.DeviceConnectionState
import dev.sebastiano.camerasync.domain.model.GpsLocation
import dev.sebastiano.camerasync.domain.model.PairedDevice
Expand Down Expand Up @@ -61,7 +62,7 @@ class DevicesListViewModel(
private val issueReporter: IssueReporter,
private val batteryOptimizationChecker: BatteryOptimizationChecker,
private val intentFactory: dev.sebastiano.camerasync.devicesync.IntentFactory,
private val ioDispatcher: CoroutineDispatcher,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {

private val _state = mutableStateOf<DevicesListState>(DevicesListState.Loading)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ private const val TAG = "LocationCollector"
* @param locationRepository The repository providing location updates.
* @param coroutineScope Scope for launching collection coroutines.
*/
class DefaultLocationCollector
@AssistedInject
constructor(
class DefaultLocationCollector(
private val locationRepository: LocationRepository,
@Assisted private val coroutineScope: CoroutineScope,
) : LocationCollectionCoordinator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,8 @@ private const val FIRMWARE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L
* - Per-device connection state
* - Broadcasting location updates to all connected devices
*/
class MultiDeviceSyncCoordinator
@AssistedInject
constructor(
class MultiDeviceSyncCoordinator(
private val context: Context,
private val cameraRepository: CameraRepository,
private val vendorRegistry: CameraVendorRegistry,
Expand Down
11 changes: 9 additions & 2 deletions app/src/main/kotlin/dev/sebastiano/camerasync/di/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import dev.sebastiano.camerasync.logging.LogRepository
import dev.sebastiano.camerasync.logging.LogViewerViewModel
import dev.sebastiano.camerasync.logging.LogcatLogRepository
import dev.sebastiano.camerasync.pairing.AndroidBluetoothBondingChecker
import dev.sebastiano.camerasync.pairing.AndroidCompanionDeviceManagerHelper
import dev.sebastiano.camerasync.pairing.BluetoothBondingChecker
import dev.sebastiano.camerasync.pairing.CompanionDeviceManagerHelper
import dev.sebastiano.camerasync.pairing.PairingViewModel
Expand Down Expand Up @@ -103,8 +104,14 @@ interface AppGraph {

@Provides
@SingleIn(AppGraph::class)
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

@Provides
@SingleIn(AppGraph::class)
@MainDispatcher
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main

/**
* Creates the default camera vendor registry with all supported vendors.
*
Expand Down Expand Up @@ -176,7 +183,7 @@ interface AppGraph {
fun provideCompanionDeviceManagerHelper(
context: Context,
vendorRegistry: CameraVendorRegistry,
): CompanionDeviceManagerHelper = CompanionDeviceManagerHelper(context, vendorRegistry)
): CompanionDeviceManagerHelper = AndroidCompanionDeviceManagerHelper(context, vendorRegistry)

@Provides
@SingleIn(AppGraph::class)
Expand All @@ -189,7 +196,7 @@ interface AppGraph {
@Provides
fun provideLogViewerViewModel(
logRepository: LogRepository,
ioDispatcher: CoroutineDispatcher,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): LogViewerViewModel = LogViewerViewModel(logRepository, ioDispatcher)

@Provides
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dev.sebastiano.camerasync.di

import dev.zacsweers.metro.Qualifier

@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher

@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MainDispatcher
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ interface CameraConnection {
/** Reads the camera's hardware revision. */
suspend fun readHardwareRevision(): String

/** Reads the camera's model name. */
suspend fun readModelName(): String

/** Sets the paired device name on the camera. */
suspend fun setPairedDeviceName(name: String)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ interface CameraGattSpec {
*/
val hardwareRevisionCharacteristicUuid: Uuid?
get() = Uuid.parse("00002a27-0000-1000-8000-00805f9b34fb")

/** Model name service UUID, or null if not supported. */
val modelNameServiceUuid: Uuid?
get() = null

/** Model name characteristic UUID, or null if not supported. */
val modelNameCharacteristicUuid: Uuid?
get() = null
}

/** Handles encoding and decoding of data for a camera vendor's BLE protocol. */
Expand Down Expand Up @@ -245,4 +253,7 @@ data class CameraCapabilities(

/** Whether the camera supports reading hardware revision. */
val supportsHardwareRevision: Boolean = false,

/** Whether the camera supports reading model name. */
val supportsModelName: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package dev.sebastiano.camerasync.pairing

import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.sebastiano.camerasync.R
import dev.sebastiano.camerasync.ui.theme.CameraSyncTheme

@Composable
internal fun AssociatingContent(modifier: Modifier = Modifier, onCancel: (() -> Unit)? = null) {
PairingStateScaffold(
modifier = modifier,
icon = {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary,
)
},
title = stringResource(R.string.pairing_state_associating),
) {
if (onCancel != null) {
TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) }
}
}
}

@Preview(name = "Associating State", showBackground = true)
@Composable
private fun AssociatingPreview() {
CameraSyncTheme { Surface { AssociatingContent() } }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package dev.sebastiano.camerasync.pairing

import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.sebastiano.camerasync.R
import dev.sebastiano.camerasync.ui.theme.CameraSyncTheme

@Composable
internal fun BondingContent(modifier: Modifier = Modifier, onCancel: (() -> Unit)? = null) {
PairingStateScaffold(
modifier = modifier,
icon = {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary,
)
},
title = stringResource(R.string.pairing_state_bonding),
) {
if (onCancel != null) {
TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) }
}
}
}

@Preview(name = "Bonding State", showBackground = true)
@Composable
private fun BondingPreview() {
CameraSyncTheme { Surface { BondingContent() } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,66 @@ import kotlin.uuid.ExperimentalUuidApi

private const val TAG = "CompanionDeviceManagerHelper"

/** Helper class for interacting with the Companion Device Manager API. */
/** Interface for interacting with the Companion Device Manager API. */
interface CompanionDeviceManagerHelper {
/**
* Initiates the association request for supported cameras.
*
* @param callback The callback to receive the IntentSender
* @param macAddress Optional MAC address to target a specific device. If provided, filters will
* be restricted to this device.
*/
fun requestAssociation(callback: CompanionDeviceManager.Callback, macAddress: String? = null)
}

/** Android implementation of [CompanionDeviceManagerHelper]. */
@OptIn(ExperimentalUuidApi::class)
class CompanionDeviceManagerHelper(
class AndroidCompanionDeviceManagerHelper(
private val context: Context,
private val vendorRegistry: CameraVendorRegistry,
) {

) : CompanionDeviceManagerHelper {
private val deviceManager: CompanionDeviceManager by lazy {
context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
}

/**
* Initiates the association request for supported cameras.
*
* @param callback The callback to receive the IntentSender
*/
fun requestAssociation(callback: CompanionDeviceManager.Callback) {
val request = buildAssociationRequest()
override fun requestAssociation(
callback: CompanionDeviceManager.Callback,
macAddress: String?,
) {
val request = buildAssociationRequest(macAddress)

Log.info(tag = TAG) { "Requesting association with Companion Device Manager" }
Log.info(tag = TAG) {
"Requesting association with Companion Device Manager (target: $macAddress)"
}
deviceManager.associate(request, context.mainExecutor, callback)
}

private fun buildAssociationRequest(): AssociationRequest {
private fun buildAssociationRequest(macAddress: String?): AssociationRequest {
val builder = AssociationRequest.Builder()

vendorRegistry.getAllVendors().forEach { vendor ->
vendor.getCompanionDeviceFilters().forEach { filter -> builder.addDeviceFilter(filter) }
if (macAddress != null) {
// Target specific device by MAC address
val deviceFilter =
android.companion.BluetoothDeviceFilter.Builder().setAddress(macAddress).build()
builder.addDeviceFilter(deviceFilter)

// Also add BLE filter for the specific device
val bleFilter =
android.companion.BluetoothLeDeviceFilter.Builder()
.setScanFilter(
android.bluetooth.le.ScanFilter.Builder()
.setDeviceAddress(macAddress)
.build()
)
.build()
builder.addDeviceFilter(bleFilter)
} else {
// Generic filters for all supported vendors
vendorRegistry.getAllVendors().forEach { vendor ->
vendor.getCompanionDeviceFilters().forEach { filter ->
builder.addDeviceFilter(filter)
}
}
}

return builder.setSingleDevice(true).build()
Expand Down
Loading