From 82ca0d7626d3fbb64467da4e09ea8adc47bed5ba Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 28 Apr 2025 11:12:17 +0200 Subject: [PATCH] Add location and battery / media status feature --- .idea/misc.xml | 2 +- app/src/main/AndroidManifest.xml | 3 + .../staacks/alpharemote/camera/CameraBLE.kt | 336 ++++++++++++++++-- .../staacks/alpharemote/camera/CameraState.kt | 16 +- .../alpharemote/service/AlphaRemoteService.kt | 80 ++++- .../ui/settings/CompanionDeviceHelper.kt | 1 + .../ui/settings/SettingsFragment.kt | 5 + .../ui/settings/SettingsViewModel.kt | 10 +- app/src/main/res/layout/fragment_camera.xml | 226 ++++++------ 9 files changed, 536 insertions(+), 143 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..a4f09e2 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9342043..8900ddb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,9 @@ + + + () private var currentOperation: CameraBLEOperation? = null @@ -53,6 +88,11 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String private var name: String? = null + private var locationSupportedByCamera = false + private var locationSendTimezone: Boolean? = null + private var locationInitDone = false + private var lastLocation: Location? = null + private var bluetoothAdapter: BluetoothAdapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter private var device: BluetoothDevice? = null @@ -78,11 +118,27 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String Log.d(MainActivity.TAG, "onServicesDiscovered") if (status == BluetoothGatt.GATT_SUCCESS) { remoteService = gatt?.getService(remoteServiceUUID) - commandCharacteristic = remoteService?.getCharacteristic(commandCharacteristicUUID) - statusCharacteristic = remoteService?.getCharacteristic(statusCharacteristicUUID) + remoteCommandCharacteristic = remoteService?.getCharacteristic(commandCharacteristicUUID) + remoteStatusCharacteristic = remoteService?.getCharacteristic(statusCharacteristicUUID) + cameraService = gatt?.getService(cameraServiceUUID) + cameraStatusCharacteristic = cameraService?.getCharacteristic(cameraStatusCharacteristicUUID) + cameraMediaCharacteristic = cameraService?.getCharacteristic(cameraMediaCharacteristicUUID) + cameraBatteryCharacteristic = cameraService?.getCharacteristic(cameraBatteryCharacteristicUUID) + locationService = gatt?.getService(locationServiceUUID) + locationNotificationCharacteristic = locationService?.getCharacteristic(locationNotificationCharacteristicUUID) + locationReceiverCharacteristic = locationService?.getCharacteristic(locationReceiverCharacteristicUUID) + locationDataFormatCharacteristic = locationService?.getCharacteristic(locationDataFormatCharacteristicUUID) + locationLockCharacteristic = locationService?.getCharacteristic(locationLockCharacteristicUUID) + locationEnabledCharacteristic = locationService?.getCharacteristic(locationEnabledCharacteristicUUID) + locationTimeCorrectionCharacteristic = locationService?.getCharacteristic(locationTimeCorrectionCharacteristicUUID) + locationAreaAdjustmentCharacteristic = locationService?.getCharacteristic(locationAreaAdjustmentCharacteristicUUID) + Log.d(MainActivity.TAG, "remote=" + (remoteService != null) + ", rCmd=" + (remoteCommandCharacteristic != null) + ", rStatus=" + (remoteStatusCharacteristic != null)) + Log.d(MainActivity.TAG, "camera=" + (cameraService != null) + ", cStatus=" + (cameraStatusCharacteristic != null) + ", cMedia=" + (cameraMediaCharacteristic != null) + ", cBattery=" + (cameraBatteryCharacteristic != null)) + Log.d(MainActivity.TAG, "location=" + (locationService != null) + ", lNotif=" + (locationNotificationCharacteristic != null) + ", lRecv=" + (locationReceiverCharacteristic != null) + ", lDataFormat=" + (locationDataFormatCharacteristic != null) + ", lLock=" + (locationLockCharacteristic != null) + ", lEnabled=" + (locationEnabledCharacteristic != null) + ", lTimeC=" + (locationTimeCorrectionCharacteristic != null) + ", lAreaA=" + (locationAreaAdjustmentCharacteristic != null)) + locationSupportedByCamera = (locationReceiverCharacteristic != null) && (locationDataFormatCharacteristic != null) && (locationLockCharacteristic != null) && (locationEnabledCharacteristic != null) && (locationTimeCorrectionCharacteristic != null) && (locationAreaAdjustmentCharacteristic != null) val nameCharacteristic = gatt?.getService(genericAccessServiceUUID)?.getCharacteristic(nameCharacteristicUUID) - if (statusCharacteristic != null && commandCharacteristic != null && nameCharacteristic != null) { - statusCharacteristic?.let { + if (remoteStatusCharacteristic != null && remoteCommandCharacteristic != null && nameCharacteristic != null) { + remoteStatusCharacteristic?.let { enqueueOperation(CameraBLERead(nameCharacteristic){ status, value -> if (status == BluetoothGatt.GATT_SUCCESS) { val newName = value.toString(Charsets.UTF_8) @@ -92,11 +148,57 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String }) enqueueOperation(CameraBLESubscribe(it)) } + if (cameraService != null && cameraStatusCharacteristic != null && cameraMediaCharacteristic != null && cameraBatteryCharacteristic != null) { + cameraMediaCharacteristic?.let { + enqueueOperation(CameraBLESubscribe(it)) + enqueueOperation(CameraBLERead(it){ status, value -> + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(MainActivity.TAG, "Initial read of media status succeeded") + onCameraMediaUpdate(value) + } + else { + Log.w(MainActivity.TAG, "Initial read of media status failed ${status}") + } + }) + } + cameraBatteryCharacteristic?.let { + enqueueOperation(CameraBLESubscribe(it)) + enqueueOperation(CameraBLERead(it){ status, value -> + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(MainActivity.TAG, "Initial read of battery status succeeded") + onCameraBatteryUpdate(value) + } + else { + Log.w(MainActivity.TAG, "Initial read of battery status failed ${status}") + } + }) + } + } + locationDataFormatCharacteristic?.let { + enqueueOperation(CameraBLERead(it){ status, value -> + if (status == BluetoothGatt.GATT_SUCCESS) { + locationSendTimezone = value.size >= 5 && value[4] and 2.toByte() == 2.toByte() + Log.d(MainActivity.TAG, "Reading location data format: sendTimezone=${locationSendTimezone}") + } + else { + Log.w(MainActivity.TAG, "Reading location data format failed ${status}") + locationSupportedByCamera = false + } + }) + } + locationLockCharacteristic?.let { + Log.d(MainActivity.TAG, "Writing location lock") + enqueueOperation(CameraBLEWrite(it, byteArrayOf(0x01.toByte()))) + } + locationEnabledCharacteristic?.let { + Log.d(MainActivity.TAG, "Writing location enabled") + enqueueOperation(CameraBLEWrite(it, byteArrayOf(0x01.toByte()))) + } } else { _cameraState.value = CameraStateError(null, "Remote service not found.") Log.e(MainActivity.TAG, "remoteService: " + remoteService.toString()) - Log.e(MainActivity.TAG, "commandCharacteristic: " + commandCharacteristic.toString()) - Log.e(MainActivity.TAG, "statusCharacteristic: " + statusCharacteristic.toString()) + Log.e(MainActivity.TAG, "commandCharacteristic: " + remoteCommandCharacteristic.toString()) + Log.e(MainActivity.TAG, "statusCharacteristic: " + remoteStatusCharacteristic.toString()) Log.e(MainActivity.TAG, "nameCharacteristic: " + nameCharacteristic.toString()) notifyDisconnect() } @@ -127,7 +229,7 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String status: Int ) { super.onCharacteristicWrite(gatt, characteristic, status) - cameraBLEWriteComplete(status) + cameraBLEWriteComplete(characteristic, status) } @Deprecated("Deprecated in Java") @@ -174,8 +276,12 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return //Use the new version of onCharacteristicRead instead Log.d(MainActivity.TAG, "Deprecated onCharacteristicChanged from ${characteristic.uuid}.") - if (characteristic == statusCharacteristic) { - onCameraStatusUpdate(characteristic.value) + when (characteristic) { + remoteStatusCharacteristic -> onRemoteStatusUpdate(characteristic.value) + cameraStatusCharacteristic -> onCameraStatusUpdate(characteristic.value) + cameraMediaCharacteristic -> onCameraMediaUpdate(characteristic.value) + cameraBatteryCharacteristic -> onCameraBatteryUpdate(characteristic.value) + locationNotificationCharacteristic -> return // TODO, maybe needed for turning the location feature on and off } } @@ -186,8 +292,12 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String ) { super.onCharacteristicChanged(gatt, characteristic, value) Log.d(MainActivity.TAG, "onCharacteristicChanged from ${characteristic.uuid}.") - if (characteristic == statusCharacteristic) { - onCameraStatusUpdate(value) + when (characteristic) { + remoteStatusCharacteristic -> onRemoteStatusUpdate(value) + cameraStatusCharacteristic -> onCameraStatusUpdate(value) + cameraMediaCharacteristic -> onCameraMediaUpdate(value) + cameraBatteryCharacteristic -> onCameraBatteryUpdate(value) + locationNotificationCharacteristic -> return // TODO } } } @@ -223,6 +333,7 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String if (device?.bondState == BluetoothDevice.BOND_BONDED) { _cameraState.value = CameraStateConnecting() gatt = device?.connectGatt(context, true, bluetoothGattCallback) + locationInitDone = false } else { _cameraState.value = CameraStateNotBonded() Log.e(MainActivity.TAG, "Camera found, but not bonded.") @@ -237,10 +348,11 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String Log.d(MainActivity.TAG, "notifyDisconnect") _cameraState.value = CameraStateGone() remoteService = null - commandCharacteristic = null - statusCharacteristic = null + remoteCommandCharacteristic = null + remoteStatusCharacteristic = null resetOperationQueue() currentOperation = null + locationInitDone = false onDisconnect() } @@ -328,13 +440,27 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String executeNextOperation() } - fun cameraBLEWriteComplete(status: Int) { + fun cameraBLEWriteComplete(characteristic: BluetoothGattCharacteristic?, status: Int) { Log.d(MainActivity.TAG, "Writing complete: $status") if (currentOperation is CameraBLEWrite) { operationComplete() + if (status == BluetoothGatt.GATT_SUCCESS) { + when (characteristic) { + locationLockCharacteristic -> Log.d(MainActivity.TAG, "Writing location lock succeeded") + locationEnabledCharacteristic -> { + Log.d(MainActivity.TAG, "Writing location enable succeeded") + locationInitDone = true + lastLocation?.let { sendLocation(it) } + } + } + } if (status == 144) { - //The command failed. This is very likely a properly bonded camera with BLE remote setting disabled - _cameraState.value = CameraStateRemoteDisabled() + when (characteristic) { + //The command failed. This is very likely a properly bonded camera with BLE remote setting disabled + remoteCommandCharacteristic -> _cameraState.value = CameraStateRemoteDisabled() + locationLockCharacteristic -> Log.w(MainActivity.TAG, "Writing location lock failed") // If a different BLE device was sending location updates to the camera before this could fail. Try unpairing the other device or disabling location linkage on the other device. + locationEnabledCharacteristic -> Log.w(MainActivity.TAG, "Writing location enable failed") + } } //Other results are ignored. If this fails for any other reason - well if the button was not pressed, the user has to try again, but it does not change anything for this app. } } @@ -355,18 +481,18 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String val name = (cameraState.value as? CameraStateIdentified)?.name if (name == null) Log.w(MainActivity.TAG, "Subscribe complete, but camera in unidentified state.") - _cameraState.value = CameraStateReady(name, focus = false, shutter = false, recording = false, emptySet(), emptySet()) + _cameraState.value = CameraStateReady(name, focus = false, shutter = false, recording = false, emptySet(), emptySet(), null, null) operationComplete() } } @OptIn(ExperimentalStdlibApi::class) - fun onCameraStatusUpdate(value: ByteArray) { + fun onRemoteStatusUpdate(value: ByteArray) { _cameraState.update { if (it is CameraStateRemoteDisabled || it is CameraStateReady) { val state = if (it is CameraStateRemoteDisabled) // The remote disabled state is the consequence of a failed write. This might be recoverable (i.e. user turned on the remote feature), so let's start with a fresh ready state. - CameraStateReady(name, focus = false, shutter = false, recording = false, emptySet(), emptySet()) + CameraStateReady(name, focus = false, shutter = false, recording = false, emptySet(), emptySet(), null, null) else it as CameraStateReady when (value[1]) { @@ -378,7 +504,171 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String } else // This should not happen. If it happens, it is probably the result of the BLE communication running in parallel to whatever changed the state. In this case it is probably not recoverable and should be ignored it } - Log.d(MainActivity.TAG, "Received status: 0x${value.toHexString()}") + Log.d(MainActivity.TAG, "Received remote status: 0x${value.toHexString()}") + } + + fun parseCameraMedia(data: ByteArray): CameraMediaStatus? { + if (data.size < 20 || data[1] != 0.toByte() || data[2] != 0.toByte() || data[3] != 2.toByte()) { + return null + } + val slot1ShotsRemaining = if ((data[4] and 2.toByte()) == 2.toByte()) + ByteBuffer.wrap(byteArrayOf(data[8], data[9], data[10], data[11])).int + else + null + val slot1SecondsRemaining = if ((data[4] and 4.toByte()) == 4.toByte()) + ByteBuffer.wrap(byteArrayOf(data[16], data[17], data[18], data[19])).int + else + null + if ((data[4] and 1.toByte()) == 1.toByte() && data[6].toInt() in 1..5 && (slot1ShotsRemaining != null || slot1SecondsRemaining != null)) { + val slot1Description = if (slot1SecondsRemaining == null) "\uD83D\uDCF7$slot1ShotsRemaining" else "\uD83C\uDFA5${DateUtils.formatElapsedTime(slot1SecondsRemaining.toLong())}" + return CameraMediaStatus(slot1ShotsRemaining, slot1SecondsRemaining, slot1Description) + } + if (data.size < 24) { + return null + } + val slot2ShotsRemaining = if ((data[5] and 2.toByte()) == 2.toByte()) + ByteBuffer.wrap(byteArrayOf(data[12], data[13], data[14], data[15])).int + else + null + val slot2SecondsRemaining = if ((data[5] and 4.toByte()) == 4.toByte()) + ByteBuffer.wrap(byteArrayOf(data[20], data[21], data[22], data[23])).int + else + null + if ((data[5] and 1.toByte()) == 1.toByte() && data[7].toInt() in 1..5 && (slot2ShotsRemaining != null || slot2SecondsRemaining != null)) { + val slot2Description = if (slot2SecondsRemaining == null) "\uD83D\uDCF7$slot2ShotsRemaining" else "\uD83C\uDFA5${DateUtils.formatElapsedTime(slot2SecondsRemaining.toLong())}" + return CameraMediaStatus(slot2ShotsRemaining, slot2SecondsRemaining, slot2Description) + } + return null + } + + fun parseCameraBattery(data: ByteArray): CameraBatteryStatus? { + if (data.size < 18 || data[1] != 0.toByte() || data[2] != 0.toByte() || data[3] != 2.toByte()) { + return null + } + val pack1Percentage = ByteBuffer.wrap(byteArrayOf(data[10], data[11], data[12], data[13])).int + val pack1Charging = (data[8].toInt() in 6..11) + if ((data[4] and 1.toByte()) == 1.toByte() && data[8].toInt() in 1..11 && pack1Percentage in 0..100) { + return CameraBatteryStatus( + percentage = pack1Percentage, + charging = pack1Charging, + description = "${if (pack1Charging) "⚡" else "\uD83D\uDD0B"}${pack1Percentage}%" + ) + } + val pack2Percentage = ByteBuffer.wrap(byteArrayOf(data[14], data[15], data[16], data[17])).int + val pack2Charging = (data[9].toInt() in 6..11) + if ((data[5] and 1.toByte()) == 1.toByte() && data[9].toInt() in 1..11 && pack2Percentage in 0..100) { + return CameraBatteryStatus( + percentage = pack2Percentage, + charging = pack2Charging, + description = "${if (pack2Charging) "⚡" else "\uD83D\uDD0B"}${pack2Percentage}%" + ) + } + return null + } + + @OptIn(ExperimentalStdlibApi::class) + fun onCameraStatusUpdate(value: ByteArray) { + Log.d(MainActivity.TAG, "Received camera status: 0x${value.toHexString()}") + } + + @OptIn(ExperimentalStdlibApi::class) + fun onCameraMediaUpdate(value: ByteArray) { + Log.d(MainActivity.TAG, "Received camera media: 0x${value.toHexString()}") + var mediaStatus = parseCameraMedia(value) + mediaStatus?.let { + Log.d(MainActivity.TAG, "Media Shots: ${it.shotsRemaining}, Seconds: ${it.secondsRemaining}, Description: ${it.description}") + } + _cameraState.update { + if (it is CameraStateReady) { + it.copy(mediaStatus = mediaStatus) + } else { + it + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + fun onCameraBatteryUpdate(value: ByteArray) { + Log.d(MainActivity.TAG, "Received camera battery: 0x${value.toHexString()}") + var batteryStatus = parseCameraBattery(value) + batteryStatus?.let { + Log.d(MainActivity.TAG, "Battery percentage: ${it.percentage}, Charging: ${it.charging}, Description: ${it.description}") + } + _cameraState.update { + if (it is CameraStateReady) { + it.copy(batteryStatus = batteryStatus) + } else { + it + } + } + } + + fun serializeLocation(location: Location): ByteArray? { + // Check if location timestamp is not too old + if (SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos > 30000000000) { + Log.w(MainActivity.TAG, "Location too old") + return null + } + + val sendTimezone = locationSendTimezone + if (sendTimezone == null) { return null } + + // Initialize data as bytes + val dataLength = if (sendTimezone) 95 else 91 + val result = ByteArray(dataLength) + + // Set initial values in the data array + byteArrayOf( + 0x00.toByte(), 0x00.toByte(), 0x08.toByte(), 0x02.toByte(), 0xfc.toByte(), 0x00.toByte(), + 0x00.toByte(), 0x00.toByte(), 0x10.toByte(), 0x10.toByte(), 0x10.toByte() + ).copyInto(result) + result[1] = (dataLength - 2).toByte() + result[5] = if (sendTimezone) 0x03.toByte() else 0x00.toByte() + + // Pack latitude and longitude into bytes + ByteBuffer.allocate(4).putInt((location.latitude * 10000000).toInt()).array().copyInto(result, 11) + ByteBuffer.allocate(4).putInt((location.longitude * 10000000).toInt()).array().copyInto(result, 15) + + // Pack date and time into bytes + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + //calendar.timeInMillis = location.time + ByteBuffer.allocate(2).putShort((calendar.get(Calendar.YEAR)).toShort()).array().copyInto(result, 19) + result[21] = calendar.get(Calendar.MONTH).plus(1).toByte() + result[22] = calendar.get(Calendar.DAY_OF_MONTH).toByte() + result[23] = calendar.get(Calendar.HOUR_OF_DAY).toByte() + result[24] = calendar.get(Calendar.MINUTE).toByte() + result[25] = calendar.get(Calendar.SECOND).toByte() + + if (sendTimezone) { + // Pack time zone and DST offsets into bytes + ByteBuffer.allocate(2).putShort( + (TimeZone.getDefault().rawOffset / 60000).toShort() + ).array().copyInto(result, 91) + ByteBuffer.allocate(2).putShort( + (if (TimeZone.getDefault().inDaylightTime(Date())) + TimeZone.getDefault().dstSavings / 60000 + else + 0 + ).toShort() + ).array().copyInto(result, 93) + } + return result + } + + @OptIn(ExperimentalStdlibApi::class) + fun sendLocation(location: Location) { + if (locationSupportedByCamera && locationInitDone && locationSendTimezone != null) { + serializeLocation(location)?.let { data -> + locationReceiverCharacteristic?.let { characteristic -> + Log.d(MainActivity.TAG, "Sending location data: 0x${data.toHexString()}") + enqueueOperation(CameraBLEWrite(characteristic, data)) + lastLocation = null + } + } + } else { + Log.d(MainActivity.TAG, "Saving location until init is done") + lastLocation = location + } } fun executeCameraActionStep(action: CameraActionStep) { @@ -386,7 +676,7 @@ class CameraBLE(val scope: CoroutineScope, context: Context, val address: String if (cameraState.value !is CameraStateReady) return try { - commandCharacteristic?.let { char -> + remoteCommandCharacteristic?.let { char -> when (action) { is CAButton -> { enqueueOperation(CameraBLEWrite(char, byteArrayOf(0x01, action.getCode()))) diff --git a/app/src/main/java/org/staacks/alpharemote/camera/CameraState.kt b/app/src/main/java/org/staacks/alpharemote/camera/CameraState.kt index fc08bb3..cc71a1d 100644 --- a/app/src/main/java/org/staacks/alpharemote/camera/CameraState.kt +++ b/app/src/main/java/org/staacks/alpharemote/camera/CameraState.kt @@ -19,10 +19,24 @@ data class CameraStateReady( val shutter: Boolean, val recording: Boolean, val pressedButtons: Set, - val pressedJogs: Set + val pressedJogs: Set, + val mediaStatus: CameraMediaStatus?, + val batteryStatus: CameraBatteryStatus? ) : CameraState() data class CameraStateError( val exception: Exception?, val description: String = "" ) : CameraState() + +data class CameraMediaStatus( + val shotsRemaining: Int?, + val secondsRemaining: Int?, + val description: String +) + +data class CameraBatteryStatus( + val percentage: Int, + val charging: Boolean, + val description: String +) \ No newline at end of file diff --git a/app/src/main/java/org/staacks/alpharemote/service/AlphaRemoteService.kt b/app/src/main/java/org/staacks/alpharemote/service/AlphaRemoteService.kt index 0c2439c..3aab825 100644 --- a/app/src/main/java/org/staacks/alpharemote/service/AlphaRemoteService.kt +++ b/app/src/main/java/org/staacks/alpharemote/service/AlphaRemoteService.kt @@ -2,16 +2,32 @@ package org.staacks.alpharemote.service import android.Manifest import android.companion.AssociationInfo -import android.companion.CompanionDeviceManager import android.companion.CompanionDeviceService -import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.content.res.Configuration +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.location.LocationRequest +import android.os.Bundle import android.os.SystemClock import android.util.Log import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.staacks.alpharemote.MainActivity import org.staacks.alpharemote.R import org.staacks.alpharemote.SettingsStore @@ -21,25 +37,12 @@ import org.staacks.alpharemote.camera.CACountdown import org.staacks.alpharemote.camera.CAJog import org.staacks.alpharemote.camera.CAWaitFor import org.staacks.alpharemote.camera.CameraAction +import org.staacks.alpharemote.camera.CameraActionPreset import org.staacks.alpharemote.camera.CameraActionStep import org.staacks.alpharemote.camera.CameraBLE import org.staacks.alpharemote.camera.CameraStateIdentified import org.staacks.alpharemote.camera.CameraStateReady import org.staacks.alpharemote.camera.WaitTarget -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.staacks.alpharemote.camera.CameraActionPreset -import org.staacks.alpharemote.ui.settings.CompanionDeviceHelper import java.util.LinkedList import java.util.Timer import java.util.TimerTask @@ -54,6 +57,8 @@ class AlphaRemoteService : CompanionDeviceService() { private var timer: TimerTask? = null private var notificationUI: NotificationUI? = null + private var locationManager: LocationManager? = null + companion object { private var cameraBLE: CameraBLE? = null @@ -82,6 +87,8 @@ class AlphaRemoteService : CompanionDeviceService() { var broadcastControl = false } + @Suppress("DEPRECATION") + @Deprecated("Deprecated in Java") override fun onDeviceAppeared(address: String) { Log.d(MainActivity.TAG, "Device appeared: $address") try { @@ -153,6 +160,8 @@ class AlphaRemoteService : CompanionDeviceService() { Log.d(MainActivity.TAG, "API33 onDeviceAppeared: $associationInfo") } + @Suppress("DEPRECATION") + @Deprecated("Deprecated in Java") override fun onDeviceDisappeared(address: String) { Log.d(MainActivity.TAG, "Device disappeared: $address") try { @@ -182,10 +191,12 @@ class AlphaRemoteService : CompanionDeviceService() { ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST ) } + startLocationUpdates() } private fun onDisconnect() { Log.d(MainActivity.TAG, "onDisconnect") + stopLocationUpdates() _serviceState.value = ServiceStateGone() cancelPendingActionSteps() stopForeground(STOP_FOREGROUND_REMOVE) @@ -215,6 +226,7 @@ class AlphaRemoteService : CompanionDeviceService() { startCameraAction(cameraAction.getReleaseStepList()) } + @Suppress("DEPRECATION") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(MainActivity.TAG, "onStartCommand: $intent") when (intent?.action) { @@ -362,4 +374,40 @@ class AlphaRemoteService : CompanionDeviceService() { } } + fun startLocationUpdates() { + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED) { + Log.w(MainActivity.TAG, "Start location updates: Location permission not granted") + return + } + Log.d(MainActivity.TAG, "Start location updates") + if (locationManager == null) { + locationManager = getSystemService(LOCATION_SERVICE) as LocationManager + } + val request = LocationRequest.Builder(10000L) + .setMinUpdateIntervalMillis(10000L) + .build() + locationManager?.requestLocationUpdates( + LocationManager.FUSED_PROVIDER, + request, + ContextCompat.getMainExecutor(this), + locationListener + ) + } + + fun stopLocationUpdates() { + Log.d(MainActivity.TAG, "Stop location updates") + locationManager?.removeUpdates(locationListener) + } + + private val locationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + Log.d(MainActivity.TAG, "Latitude: ${location.latitude}, Longitude: ${location.longitude}") + cameraBLE?.sendLocation(location) + } + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) {} + } } \ No newline at end of file diff --git a/app/src/main/java/org/staacks/alpharemote/ui/settings/CompanionDeviceHelper.kt b/app/src/main/java/org/staacks/alpharemote/ui/settings/CompanionDeviceHelper.kt index a805772..f2e293f 100644 --- a/app/src/main/java/org/staacks/alpharemote/ui/settings/CompanionDeviceHelper.kt +++ b/app/src/main/java/org/staacks/alpharemote/ui/settings/CompanionDeviceHelper.kt @@ -46,6 +46,7 @@ object CompanionDeviceHelper { //with remote disabled it is 0x03006400453122e800... //This would suggest that we have to check for 0x04. //So, until we can verify this on a few different models, we do not check this bit to ensure compatibility + //For the a6700 we have to check for 0x04 in the 8th byte. (Remote disabled: 0x03006500553122bb..., Remote enabled: 0x03006500553122bf...) ) .build() ) diff --git a/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsFragment.kt b/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsFragment.kt index 5fd9f6b..b6149f5 100644 --- a/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsFragment.kt @@ -95,6 +95,7 @@ class SettingsFragment : Fragment(), CustomButtonListEventReceiver, CameraAction SettingsViewModel.SettingsUIAction.UNPAIR -> unpair() SettingsViewModel.SettingsUIAction.REQUEST_BLUETOOTH_PERMISSION -> requestBluetoothPermission(bluetoothRequestPermissionLauncher, true) SettingsViewModel.SettingsUIAction.REQUEST_NOTIFICATION_PERMISSION -> requestNotificationPermission(true) + SettingsViewModel.SettingsUIAction.REQUEST_LOCATION_PERMISSION -> requestLocationPermission() SettingsViewModel.SettingsUIAction.ADD_CUSTOM_BUTTON -> addCustomButton() SettingsViewModel.SettingsUIAction.HELP_CONNECTION -> HelpDialogFragment().setContent( @@ -242,6 +243,10 @@ class SettingsFragment : Fragment(), CustomButtonListEventReceiver, CameraAction } } + private fun requestLocationPermission() { + // TODO + } + private val bluetoothRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { Log.d(MainActivity.TAG, "Bluetooth permission granted.") diff --git a/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsViewModel.kt b/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsViewModel.kt index cbb35a0..635620e 100644 --- a/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/org/staacks/alpharemote/ui/settings/SettingsViewModel.kt @@ -39,6 +39,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application var cameraName: String?, var bluetoothPermissionGranted: Boolean, var notificationPermissionGranted: Boolean, + var locationPermissionGranted: Boolean, var bluetoothEnabled: Boolean ) @@ -56,12 +57,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application UNPAIR, REQUEST_BLUETOOTH_PERMISSION, REQUEST_NOTIFICATION_PERMISSION, + REQUEST_LOCATION_PERMISSION, ADD_CUSTOM_BUTTON, HELP_CONNECTION, HELP_CUSTOM_BUTTONS } - private val _uiState = MutableStateFlow(SettingsUIState(cameraState = SettingsUICameraState.OFFLINE, cameraError = null, cameraName = null, bluetoothPermissionGranted = true, notificationPermissionGranted = true, bluetoothEnabled = false)) + private val _uiState = MutableStateFlow(SettingsUIState(cameraState = SettingsUICameraState.OFFLINE, cameraError = null, cameraName = null, bluetoothPermissionGranted = true, notificationPermissionGranted = true, locationPermissionGranted = true, bluetoothEnabled = false)) val uiState: StateFlow = _uiState.asStateFlow() private val _uiAction = MutableSharedFlow() @@ -200,6 +202,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application } } + fun requestLocationPermission() { + viewModelScope.launch { + _uiAction.emit(SettingsUIAction.REQUEST_LOCATION_PERMISSION) + } + } + fun addCustomButton() { viewModelScope.launch { _uiAction.emit(SettingsUIAction.ADD_CUSTOM_BUTTON) diff --git a/app/src/main/res/layout/fragment_camera.xml b/app/src/main/res/layout/fragment_camera.xml index a426902..9ffb5a5 100644 --- a/app/src/main/res/layout/fragment_camera.xml +++ b/app/src/main/res/layout/fragment_camera.xml @@ -66,107 +66,131 @@ android:layout_height="match_parent" android:layout_margin="4dp" android:visibility="@{viewModel.uiState.connected ? View.VISIBLE : View.GONE}" - > - - - - - - - - - - - - - - - - - - - - - - + tools:layout_editor_absoluteX="4dp" + tools:layout_editor_absoluteY="4dp"> + + + + + + + + + + + + + + + + + + + + + + + + + +