diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..1a1bf72 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f93fa1b..eb01a5a 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 = ReportedBoolean(), shutter = ReportedBoolean(), recording = ReportedBoolean(), emptySet(), emptySet()) + _cameraState.value = CameraStateReady(name, focus = ReportedBoolean(), shutter = ReportedBoolean(), recording = ReportedBoolean(), 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 = ReportedBoolean(), shutter = ReportedBoolean(), recording = ReportedBoolean(), emptySet(), emptySet()) + CameraStateReady(name, focus = ReportedBoolean(), shutter = ReportedBoolean(), recording = ReportedBoolean(), 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 f1d6967..8b0d4f8 100644 --- a/app/src/main/java/org/staacks/alpharemote/camera/CameraState.kt +++ b/app/src/main/java/org/staacks/alpharemote/camera/CameraState.kt @@ -33,10 +33,24 @@ data class CameraStateReady( val shutter: ReportedBoolean, val recording: ReportedBoolean, 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 62196ff..bb5b426 100644 --- a/app/src/main/java/org/staacks/alpharemote/service/AlphaRemoteService.kt +++ b/app/src/main/java/org/staacks/alpharemote/service/AlphaRemoteService.kt @@ -3,17 +3,33 @@ package org.staacks.alpharemote.service import android.Manifest import android.annotation.SuppressLint 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.PowerManager 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 @@ -23,25 +39,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 @@ -56,6 +59,7 @@ class AlphaRemoteService : CompanionDeviceService() { private var timer: TimerTask? = null private var notificationUI: NotificationUI? = null + private var locationManager: LocationManager? = null private lateinit var pendingActionsWakeLock: PowerManager.WakeLock companion object { @@ -84,6 +88,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 { @@ -159,6 +165,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 { @@ -191,10 +199,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) @@ -225,6 +235,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) { @@ -404,4 +415,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 b79cbf1..928bd74 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 @@ -50,6 +50,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 48c593d..b2fef63 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 @@ -97,6 +97,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( @@ -263,6 +264,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 eff35c6..733dbf3 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 @@ -41,6 +41,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application var cameraName: String?, var bluetoothPermissionGranted: Boolean, var notificationPermissionGranted: Boolean, + var locationPermissionGranted: Boolean, var bluetoothEnabled: Boolean, var locationServiceEnabled: Boolean, var bleScanningEnabled: Boolean @@ -60,12 +61,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, locationServiceEnabled = false, bleScanningEnabled = false)) + private val _uiState = MutableStateFlow(SettingsUIState(cameraState = SettingsUICameraState.OFFLINE, cameraError = null, cameraName = null, bluetoothPermissionGranted = true, notificationPermissionGranted = true, locationPermissionGranted = true, bluetoothEnabled = false, locationServiceEnabled = false, bleScanningEnabled = false)) val uiState: StateFlow = _uiState.asStateFlow() private val _uiAction = MutableSharedFlow() @@ -210,6 +212,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 58e4514..c43980f 100644 --- a/app/src/main/res/layout/fragment_camera.xml +++ b/app/src/main/res/layout/fragment_camera.xml @@ -66,9 +66,11 @@ 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">