From 38646f35529ce10a06beccd4837fe5ec0d9615b1 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Tue, 17 Sep 2024 13:30:31 -0500 Subject: [PATCH 01/28] feat: add gatt bluetooth callback Signed-off-by: Reyva Babtista (cherry picked from commit e663fff2ffbb5a7895db6aff1e64d287902bfe05) --- .../beiwe/app/listeners/BluetoothListener.kt | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt b/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt index 62464749..b087e7e5 100644 --- a/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt @@ -3,10 +3,14 @@ package org.beiwe.app.listeners import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter.LeScanCallback +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build import android.util.Log import org.beiwe.app.storage.EncryptionEngine import org.beiwe.app.storage.PersistentData @@ -54,6 +58,8 @@ class BluetoothListener : BroadcastReceiver() { // the access to the bluetooth adaptor private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() + private var bluetoothGatt: BluetoothGatt? = null + // bluetoothExists can be set to false if the device does not meet our needs. private var bluetoothExists: Boolean? = null @@ -185,7 +191,56 @@ class BluetoothListener : BroadcastReceiver() { TextFileManager.getBluetoothLogFile().writeEncrypted( System.currentTimeMillis().toString() + "," + EncryptionEngine.hashMAC(device.toString()) + "," + rssi ) - // Log.i("Bluetooth", System.currentTimeMillis() + "," + device.toString() + ", " + rssi ) +// Log.i("Bluetooth", System.currentTimeMillis().toString() + "," + device.toString() + ", " + rssi ) + if (device.name == "PPG_Ring#1") { + bluetoothGatt = device.connectGatt(null, false, bluetoothGattCallback) + } + } + + private val bluetoothGattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + bluetoothGatt?.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + super.onServicesDiscovered(gatt, status) + if (status == BluetoothGatt.GATT_SUCCESS) { + bluetoothGatt?.services?.forEach { service -> + service.characteristics.forEach { characteristic -> + bluetoothGatt?.readCharacteristic(characteristic) + } + } + } + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + // TODO + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic? + ) { + // TODO + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + // TODO + } } From 8c901b539d836d4c3d2d00319df4473d83d8a82b Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:12:03 -0500 Subject: [PATCH 02/28] feat: add BLE service Signed-off-by: Reyva Babtista (cherry picked from commit 1c77565998c66f8e930ae2e075a78c0ff47b36a0) --- .../org/beiwe/app/listeners/BLEService.kt | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 app/src/main/java/org/beiwe/app/listeners/BLEService.kt diff --git a/app/src/main/java/org/beiwe/app/listeners/BLEService.kt b/app/src/main/java/org/beiwe/app/listeners/BLEService.kt new file mode 100644 index 00000000..ff647f13 --- /dev/null +++ b/app/src/main/java/org/beiwe/app/listeners/BLEService.kt @@ -0,0 +1,278 @@ +package org.beiwe.app.listeners + +import android.app.Service +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.content.Intent +import android.os.IBinder +import android.util.Log +import org.beiwe.app.storage.EncryptionEngine +import org.beiwe.app.storage.TextFileManager +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID + +class BLEService : Service() { + // private val bluetoothManager: BluetoothManager = +// getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager +// private val binder = LocalBinder() + private var bluetoothAdapter: BluetoothAdapter? = null + private var bluetoothGatt: BluetoothGatt? = null + private var connectionState = STATE_DISCONNECTED + var bluetoothLeScanner: BluetoothLeScanner? = null + + companion object { + private const val TAG = "BLEService" + const val ACTION_GATT_CONNECTED = + "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_CONNECTED" + const val ACTION_GATT_DISCONNECTED = + "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_DISCONNECTED" + const val ACTION_GATT_SERVICES_DISCOVERED = + "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_SERVICES_DISCOVERED" + const val ACTION_DATA_AVAILABLE = + "dev.rbabtista.kmm_phenotyping.external.ACTION_DATA_AVAILABLE" + const val EXTRA_DATA = "dev.rbabtista.kmm_phenotyping.external.EXTRA_DATA" + private const val CLIENT_CHARACTERISTIC_CONFIG = "00002902-0000-1000-8000-00805f9b34fb" + + private const val STATE_DISCONNECTED = 0 + private const val STATE_CONNECTED = 2 + + @JvmField + var omniring_header = + "timestamp,PPG_red,PPG_IR,PPG_Green,IMU_Accel_x,IMU_Accel_y,IMU_Accel_z,IMU_Gyro_x,IMU_Gyro_y,IMU_Gyro_z,IMU_Mag_x,IMU_Mag_y,IMU_Mag_z,timestamp" + + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + +// inner class LocalBinder : Binder() { +// fun getService(): BLEService { +// return this@BLEService +// } +// } + + fun initialize(): Boolean { + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + if (bluetoothAdapter == null) { + Log.e(TAG, "Unable to obtain a BluetoothAdapter.") + return false + } + bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner + TextFileManager.getBluetoothLogFile().newFile() + return true + } + + private fun broadcastUpdate(action: String) { + val intent = Intent(action) + sendBroadcast(intent) + } + + private fun broadcastUpdate(action: String, characteristic: BluetoothGattCharacteristic) { + val intent = Intent(action) + + // For all other profiles, writes the data formatted in HEX. + val data: ByteArray? = characteristic.value + if (data?.isNotEmpty() == true) { + val hexString: String = data.joinToString(separator = " ") { + String.format("%02X", it) + } + intent.putExtra(EXTRA_DATA, "${characteristic.uuid} || $data\n$hexString") + } + sendBroadcast(intent) + } + + private val bluetoothGattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + connectionState = STATE_CONNECTED + broadcastUpdate(ACTION_GATT_CONNECTED) + bluetoothGatt?.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + connectionState = STATE_DISCONNECTED + broadcastUpdate(ACTION_GATT_DISCONNECTED) + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + super.onServicesDiscovered(gatt, status) + if (status == BluetoothGatt.GATT_SUCCESS) { + broadcastUpdate(ACTION_GATT_SERVICES_DISCOVERED) + } + + enableNotification( + serviceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e", + characteristicUUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" + ) + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + broadcastUpdate(ACTION_DATA_AVAILABLE, characteristic) + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic? + ) { + Log.d( + TAG, + "characteristic changed: ${decodeByteData(characteristic?.value ?: byteArrayOf())}" + ) + TextFileManager.getOmniRingLog().writeEncrypted( + System.currentTimeMillis().toString() + "," + + decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") + ) + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + Log.d(TAG, "descriptor written: ${descriptor?.value?.toHexString()}") + } + } + + fun unpackFByteArray(byteArray: ByteArray): Float { + val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN) + return buffer.float + } + + fun decodeByteData(byteData: ByteArray): List { + val floatArray = mutableListOf() + for (i in byteData.indices step 4) { + val tmpFloat = unpackFByteArray(byteData.copyOfRange(i, i + 4)) + floatArray.add(tmpFloat) + } + return floatArray + } + + private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } + + fun getSupportedGattServices(): List? { + return if (bluetoothGatt == null) null else bluetoothGatt?.services + } + + fun connect(address: String): Boolean { + bluetoothAdapter?.let { adapter -> + try { + val device = adapter.getRemoteDevice(address) + bluetoothGatt = device.connectGatt(this, false, bluetoothGattCallback) + return true + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Invalid address.") + return false + } + } ?: run { + Log.e(TAG, "BluetoothAdapter not initialized.") + return false + } + } + + fun readCharacteristic(characteristic: BluetoothGattCharacteristic) { + bluetoothGatt?.let { gatt -> + gatt.readCharacteristic(characteristic) + } ?: run { + Log.w(TAG, "BluetoothGatt not initialized") + return + } + } + + // Enable notifications + fun enableNotification( + serviceUUID: String, + characteristicUUID: String + ) { + val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) + val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) + if (characteristic != null) { + // Enable notifications + bluetoothGatt?.setCharacteristicNotification(characteristic, true) + + // Configure the descriptor for notifications + val descriptor = + characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + bluetoothGatt?.writeDescriptor(descriptor) + } + } + + // Disable notifications + fun disableNotification( + serviceUUID: String, + characteristicUUID: String + ) { + val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) + val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) + if (characteristic != null) { + // Disable notifications + bluetoothGatt?.setCharacteristicNotification(characteristic, false) + + // Configure the descriptor for notifications + val descriptor = + characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) + descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + bluetoothGatt?.writeDescriptor(descriptor) + } + } + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: android.bluetooth.le.ScanResult) { + super.onScanResult(callbackType, result) + val device = result.device + val rssi = result.rssi +// Log.i( +// "Bluetooth", +// System.currentTimeMillis().toString() + "," + device + ", " + rssi +// ) + TextFileManager.getBluetoothLogFile().writeEncrypted( + System.currentTimeMillis() + .toString() + "," + EncryptionEngine.hashMAC(device.toString()) + "," + rssi + ) + if (device.name == "PPG_Ring#1") { + Log.d(TAG, "onScanResult: found device, connecting") + TextFileManager.getOmniRingLog().newFile() + if (device.bondState != BluetoothAdapter.STATE_CONNECTED) + connect(device.address) + } + } + + override fun onScanFailed(errorCode: Int) { + super.onScanFailed(errorCode) + Log.e("Bluetooth", "Scan failed with error code: $errorCode") + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + initialize() + bluetoothLeScanner?.startScan(scanCallback) + return START_STICKY + } + + override fun onDestroy() { + close() + super.onDestroy() + } + + private fun close() { + bluetoothGatt?.let { gatt -> + gatt.close() + bluetoothGatt = null + } + } +} \ No newline at end of file From 60e64cf7f599dde567ea20b43a45ef47577f8c30 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:13:04 -0500 Subject: [PATCH 03/28] refactor: reconfigure bluetooth listener Signed-off-by: Reyva Babtista (cherry picked from commit d15aaedc93ad5ef54f37e42b3f162295665df19c) --- .../beiwe/app/listeners/BluetoothListener.kt | 191 ++++++------------ 1 file changed, 61 insertions(+), 130 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt b/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt index b087e7e5..c2cc3311 100644 --- a/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt @@ -2,19 +2,12 @@ package org.beiwe.app.listeners import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothAdapter.LeScanCallback -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCallback -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothGattService import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log -import org.beiwe.app.storage.EncryptionEngine import org.beiwe.app.storage.PersistentData -import org.beiwe.app.storage.TextFileManager import java.util.Date // import android.content.pm.PackageManager; @@ -53,12 +46,14 @@ class BluetoothListener : BroadcastReceiver() { fun getScanActive(): Boolean { return scanActive } + + private const val CLIENT_CHARACTERISTIC_CONFIG = "00002902-0000-1000-8000-00805f9b34fb" + private const val TAG = "BluetoothListener" } // the access to the bluetooth adaptor private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() - - private var bluetoothGatt: BluetoothGatt? = null + private var bluetoothService: BLEService? = null // bluetoothExists can be set to false if the device does not meet our needs. private var bluetoothExists: Boolean? = null @@ -103,9 +98,9 @@ class BluetoothListener : BroadcastReceiver() { // this check was incorrect for 13 months, however bonded devices are not the same as connected devices. // This check was never relevent before (nobody ever noticed), so now we are just removing the check entirely. // If we want to implement more bluetooth safety checks, see http://stackoverflow.com/questions/3932228/list-connected-bluetooth-devices - // if ( bluetoothAdapter.getBondedDevices().isEmpty() ) { - // Log.d("BluetoothListener", "found a bonded bluetooth device, will not be turning off bluetooth."); - // externalBluetoothState = true; } + // if ( bluetoothAdapter.getBondedDevices().isEmpty() ) { + // Log.d("BluetoothListener", "found a bonded bluetooth device, will not be turning off bluetooth."); + // externalBluetoothState = true; } if (!externalBluetoothState) { // if the outside world and us agree that it should be off, turn it off bluetoothAdapter!!.disable() @@ -149,7 +144,6 @@ class BluetoothListener : BroadcastReceiver() { } else { enableBluetooth() } - TextFileManager.getBluetoothLogFile().newFile() } /** Intelligently and safely disables bluetooth. @@ -164,85 +158,21 @@ class BluetoothListener : BroadcastReceiver() { } Log.i("BluetoothListener", "disable BLE scan.") scanActive = false - bluetoothAdapter!!.stopLeScan(bluetoothCallback) - disableBluetooth() +// bluetoothService?.bluetoothLeScanner?.stopScan(scanCallback) +// disableBluetooth() } /** Intelligently ACTUALLY STARTS a Bluetooth LE scan. * If Bluetooth is available, start scanning. Makes verbose logging statements */ - @Suppress("deprecation") - @SuppressLint("NewApi") private fun tryScanning() { - Log.i("bluetooth", "starting a scan: " + scanActive) if (isBluetoothEnabled) { - if (bluetoothAdapter!!.startLeScan(bluetoothCallback)) { /*Log.d("bluetooth", "bluetooth LE scan started successfully.");*/ - } else { - Log.w("bluetooth", "bluetooth LE scan NOT started successfully.") - } + Log.i("bluetooth", "starting a scan: $scanActive") +// bluetoothService?.bluetoothLeScanner?.startScan(scanCallback) } else { Log.w("bluetooth", "bluetooth could not be enabled?") } } - /** LeScanCallback is code that is run when a Bluetooth LE scan returns some data. - * We take the returned data and log it. */ - @SuppressLint("NewApi") - private val bluetoothCallback = LeScanCallback { device, rssi, scanRecord -> - TextFileManager.getBluetoothLogFile().writeEncrypted( - System.currentTimeMillis().toString() + "," + EncryptionEngine.hashMAC(device.toString()) + "," + rssi - ) -// Log.i("Bluetooth", System.currentTimeMillis().toString() + "," + device.toString() + ", " + rssi ) - if (device.name == "PPG_Ring#1") { - bluetoothGatt = device.connectGatt(null, false, bluetoothGattCallback) - } - } - - private val bluetoothGattCallback = object : BluetoothGattCallback() { - override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - if (newState == BluetoothProfile.STATE_CONNECTED) { - bluetoothGatt?.discoverServices() - } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { - } - } - - override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { - super.onServicesDiscovered(gatt, status) - if (status == BluetoothGatt.GATT_SUCCESS) { - bluetoothGatt?.services?.forEach { service -> - service.characteristics.forEach { characteristic -> - bluetoothGatt?.readCharacteristic(characteristic) - } - } - } - } - - override fun onCharacteristicRead( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - value: ByteArray, - status: Int - ) { - if (status == BluetoothGatt.GATT_SUCCESS) { - // TODO - } - } - - override fun onCharacteristicChanged( - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic? - ) { - // TODO - } - - override fun onDescriptorWrite( - gatt: BluetoothGatt?, - descriptor: BluetoothGattDescriptor?, - status: Int - ) { - // TODO - } - } - /*#################################################################################### ################# the onReceive Stack for Bluetooth state messages ################### @@ -255,57 +185,58 @@ class BluetoothListener : BroadcastReceiver() { * Additionally, if a Bluetooth On notification comes in AND the scanActive variable is set to TRUE * we start a Bluetooth LE scan. */ override fun onReceive(context: Context, intent: Intent) { - val action = intent.action - if (action == BluetoothAdapter.ACTION_STATE_CHANGED) { - val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) - if (state == BluetoothAdapter.ERROR) { - Log.e("bluetooth", "BLUETOOTH ADAPTOR ERROR?") - } else if (state == BluetoothAdapter.STATE_ON) { - // Log.i("bluetooth", "state change: on" ); - if (scanActive) - enableBLEScan() - - } else if (state == BluetoothAdapter.STATE_TURNING_ON) { - // Log.i("bluetooth", "state change: turning on"); - if (!internalBluetoothState) - externalBluetoothState = true - - } else if (state == BluetoothAdapter.STATE_TURNING_OFF) { - // Log.i("bluetooth", "state change: turning off"); - if (internalBluetoothState) - externalBluetoothState = false + when (intent.action) { + BLEService.ACTION_GATT_CONNECTED -> { + Log.d(TAG, "gatt connected") + } + + BLEService.ACTION_GATT_DISCONNECTED -> { + Log.d(TAG, "gatt disconnected") + } + + BLEService.ACTION_GATT_SERVICES_DISCOVERED -> { + Log.d(TAG, "gatt services discovered") + displayGattServices(bluetoothService?.getSupportedGattServices()) + } + + BLEService.ACTION_DATA_AVAILABLE -> { + intent.extras?.getString(BLEService.EXTRA_DATA)?.let { + Log.d(TAG, "gatt data $it") + } + } + + BluetoothAdapter.ACTION_STATE_CHANGED -> { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.ERROR) { + Log.e("bluetooth", "BLUETOOTH ADAPTOR ERROR?") + } else if (state == BluetoothAdapter.STATE_ON) { + val gattServiceIntent = Intent(context, BLEService::class.java) + context.startService(gattServiceIntent) + + if (scanActive) + enableBLEScan() + + } else if (state == BluetoothAdapter.STATE_TURNING_ON) { + if (!internalBluetoothState) + externalBluetoothState = true + + } else if (state == BluetoothAdapter.STATE_TURNING_OFF) { + if (internalBluetoothState) + externalBluetoothState = false + + } } } } - /*############################################################################### - ############################# Debugging Code #################################### - ###############################################################################*/ - // val state: String - // get() { - // if (!bluetoothExists!!) - // return "does not exist." - // val state = bluetoothAdapter!!.state - // // STATE_OFF, STATE_TURNING_ON, STATE_ON, STATE_TURNING_OFF - // if (state == BluetoothAdapter.STATE_OFF) - // return "off" - // else if (state == BluetoothAdapter.STATE_TURNING_ON) - // return "turning on" - // else if (state == BluetoothAdapter.STATE_ON) - // return "on" - // else if (state == BluetoothAdapter.STATE_TURNING_OFF) - // return "turning off" - // else - // return "getstate is broken, value was $state" - // } - // - // fun bluetoothInfo() { - // Log.i("bluetooth", "bluetooth existence: " + bluetoothExists.toString()) - // Log.i("bluetooth", "bluetooth enabled: " + isBluetoothEnabled) - // // Log.i("bluetooth", "bluetooth address: " + bluetoothAdapter!!.address) - // Log.i("bluetooth", "bluetooth state: " + state) - // Log.i("bluetooth", "bluetooth scan mode: " + bluetoothAdapter.scanMode) - // Log.i("bluetooth", "bluetooth bonded devices:" + bluetoothAdapter.bondedDevices) - // } + private fun displayGattServices(gattServices: List?) { + gattServices?.forEach { service -> + Log.d(TAG, "gatt service: ${service.uuid}") + service.characteristics?.forEach { characteristic -> + Log.d(TAG, "gatt service characteristic: ${characteristic.uuid}") + bluetoothService?.readCharacteristic(characteristic) + } + } + } } \ No newline at end of file From 78fd2d8ec43d0b6f450bea3aa4bd33679c5c3f89 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:13:15 -0500 Subject: [PATCH 04/28] feat: add omniring gatt listener Signed-off-by: Reyva Babtista (cherry picked from commit e7d03f92584829c59201be56ab8d727f7eb75e5a) --- .../app/listeners/OmniRingGattListener.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt new file mode 100644 index 00000000..17df2168 --- /dev/null +++ b/app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt @@ -0,0 +1,87 @@ +package org.beiwe.app.listeners + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.util.Log +import org.beiwe.app.storage.TextFileManager +import java.nio.ByteBuffer +import java.nio.ByteOrder + +object OmniRingGattListener : BluetoothGattCallback() { + private const val TAG = "OmniRingGattListener" + var lineCount = 0 + + + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "onConnectionStateChange: connected") + gatt?.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + } + } + + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + super.onServicesDiscovered(gatt, status) + if (status == BluetoothGatt.GATT_SUCCESS) { + gatt?.services?.forEach { service -> + Log.d(TAG, "gatt service: ${service.uuid}") + service.characteristics?.forEach { characteristic -> + Log.d(TAG, "gatt service characteristic: ${characteristic.uuid}") + gatt?.readCharacteristic(characteristic) + } + } + } + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + // TODO + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic? + ) { + if (lineCount > 1000) { + TextFileManager.getOmniRingLog().newFile() + lineCount = 0 + } + val data = decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") + "\n" + Log.d(TAG, "onCharacteristicChanged: $data") + TextFileManager.getOmniRingLog().writeEncrypted(data) + lineCount++ + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + // TODO + } + + fun unpackFByteArray(byteArray: ByteArray): Float { + val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN) + return buffer.float + } + + fun decodeByteData(byteData: ByteArray): List { + val floatArray = mutableListOf() + for (i in byteData.indices step 4) { + val tmpFloat = unpackFByteArray(byteData.copyOfRange(i, i + 4)) + floatArray.add(tmpFloat) + } + return floatArray + } + +} \ No newline at end of file From d88d9057235e15a58b485d160207e59cb7f48f01 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:13:48 -0500 Subject: [PATCH 05/28] feat: add omniring constants Signed-off-by: Reyva Babtista (cherry picked from commit e008e433e7c6ee7f8f4361abf05432de813571d2) --- .../org/beiwe/app/storage/PersistentData.kt | 63 +++++++++++++++---- .../beiwe/app/storage/SetDeviceSettings.kt | 5 ++ .../beiwe/app/storage/TextFileManager.java | 17 ++++- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt index 283c6b43..5ccf92c8 100644 --- a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt +++ b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt @@ -34,6 +34,7 @@ const val CALLS_ENABLED = "calls" const val TEXTS_ENABLED = "texts" const val WIFI_ENABLED = "wifi" const val BLUETOOTH_ENABLED = "bluetooth" +const val OMNIRING_ENABLED = "omniring" const val POWER_STATE_ENABLED = "power_state" const val AMBIENT_AUDIO_ENABLED = "ambient_audio" const val ALLOW_UPLOAD_OVER_CELLULAR_DATA = "allow_upload_over_cellular_data" @@ -52,6 +53,9 @@ const val GYROSCOPE_FREQUENCY = "gyro_frequency" const val BLUETOOTH_ON_SECONDS = "bluetooth_on_duration_seconds" const val BLUETOOTH_TOTAL_SECONDS = "bluetooth_total_duration_seconds" const val BLUETOOTH_GLOBAL_OFFSET_SECONDS = "bluetooth_global_offset_seconds" +const val OMNIRING_ON_SECONDS = "omniring_on_duration_seconds" +const val OMNIRING_TOTAL_SECONDS = "omniring_total_duration_seconds" +const val OMNIRING_GLOBAL_OFFSET_SECONDS = "omniring_global_offset_seconds" const val CHECK_FOR_NEW_SURVEYS_FREQUENCY_SECONDS = "check_for_new_surveys_frequency_seconds" const val CREATE_NEW_DATA_FILES_FREQUENCY_SECONDS = "create_new_data_files_frequency_seconds" const val GPS_OFF_SECONDS = "gps_off_duration_seconds" @@ -99,6 +103,8 @@ const val MOST_RECENT_AMBIENT_AUDIO_START = "most_recent_ambient_audio_start" const val MOST_RECENT_AMBIENT_AUDIO_STOP = "most_recent_ambient_audio_stop" const val MOST_RECENT_BLUETOOTH_START = "most_recent_bluetooth_start" const val MOST_RECENT_BLUETOOTH_STOP = "most_recent_bluetooth_stop" +const val MOST_RECENT_OMNIRING_START = "most_recent_omniring_start" +const val MOST_RECENT_OMNIRING_STOP = "most_recent_omniring_stop" const val MOST_RECENT_GPS_START = "most_recent_gps_start" const val MOST_RECENT_GPS_STOP = "most_recent_gps_stop" const val MOST_RECENT_GYROSCOPE_START = "most_recent_gyroscope_start" @@ -185,8 +191,15 @@ object PersistentData { // IS_REGISTERED @JvmStatic fun getIsRegistered(): Boolean { return pref.getBoolean(IS_REGISTERED, false) } @JvmStatic fun setIsRegistered(value: Boolean) { putCommit(IS_REGISTERED, value) } - @JvmStatic fun setLastRequestedPermission(value: String) { putCommit(LAST_REQUESTED_PERMISSION, value) } - @JvmStatic fun getLastRequestedPermission(): String { return pref.getString(LAST_REQUESTED_PERMISSION, "")?: "" } + @JvmStatic + fun setLastRequestedPermission(value: String) { + putCommit(LAST_REQUESTED_PERMISSION, value) + } + + @JvmStatic + fun getLastRequestedPermission(): String { + return pref.getString(LAST_REQUESTED_PERMISSION, "") ?: "" + } @JvmStatic fun getTakingSurvey(): Boolean { return pref.getBoolean(IS_TAKING_SURVEY, false) } @JvmStatic fun setTakingSurvey() { putCommit(IS_TAKING_SURVEY, true) } @JvmStatic fun setNotTakingSurvey() { putCommit(IS_TAKING_SURVEY, false) } @@ -221,8 +234,9 @@ object PersistentData { @JvmStatic var appOnServiceStart: String get() = pref.getString(MOST_RECENT_SERVICE_START, "")?: "" set(value) = putCommit(MOST_RECENT_SERVICE_START, value) - @JvmStatic var appOnServiceStartFirstRun: String - get() = pref.getString(MOST_RECENT_SERVICE_START_FIRST_RUN, "")?: "" + @JvmStatic + var appOnServiceStartFirstRun: String + get() = pref.getString(MOST_RECENT_SERVICE_START_FIRST_RUN, "") ?: "" set(value) = putCommit(MOST_RECENT_SERVICE_START_FIRST_RUN, value) // app activity recent events @@ -261,6 +275,14 @@ object PersistentData { @JvmStatic var bluetoothStop: String get() = pref.getString(MOST_RECENT_BLUETOOTH_STOP, "")?: "" set(value) = putCommit(MOST_RECENT_BLUETOOTH_STOP, value) + @JvmStatic + var omniringStart: String + get() = pref.getString(MOST_RECENT_OMNIRING_START, "") ?: "" + set(value) = putCommit(MOST_RECENT_OMNIRING_START, value) + @JvmStatic + var omniringStop: String + get() = pref.getString(MOST_RECENT_OMNIRING_STOP, "") ?: "" + set(value) = putCommit(MOST_RECENT_OMNIRING_STOP, value) @JvmStatic var gpsStart: String get() = pref.getString(MOST_RECENT_GPS_START, "")?: "" set(value) = putCommit(MOST_RECENT_GPS_START, value) @@ -273,11 +295,13 @@ object PersistentData { @JvmStatic var gyroscopeStop: String get() = pref.getString(MOST_RECENT_GYROSCOPE_STOP, "")?: "" set(value) = putCommit(MOST_RECENT_GYROSCOPE_STOP, value) - @JvmStatic var appUploadAttempt: String - get() = pref.getString(MOST_RECENT_UPLOAD_ATTEMPT, "")?: "" + @JvmStatic + var appUploadAttempt: String + get() = pref.getString(MOST_RECENT_UPLOAD_ATTEMPT, "") ?: "" set(value) = putCommit(MOST_RECENT_UPLOAD_ATTEMPT, value) - @JvmStatic var appUploadStart: String - get() = pref.getString(MOST_RECENT_UPLOAD_START, "")?: "" + @JvmStatic + var appUploadStart: String + get() = pref.getString(MOST_RECENT_UPLOAD_START, "") ?: "" set(value) = putCommit(MOST_RECENT_UPLOAD_START, value) @JvmStatic var registrationPhoneNumberEverPrompted: Boolean @@ -318,8 +342,24 @@ object PersistentData { @JvmStatic fun setAmbientAudioCollectionIsEnabled(enabled: Boolean): Boolean { return putCommit(AMBIENT_AUDIO_ENABLED, enabled) } @JvmStatic fun getBluetoothEnabled(): Boolean { return pref.getBoolean(BLUETOOTH_ENABLED, false) } @JvmStatic fun setBluetoothEnabled(enabled: Boolean): Boolean { return putCommit(BLUETOOTH_ENABLED, enabled) } - @JvmStatic fun getCallLoggingEnabled(): Boolean { return pref.getBoolean(CALLS_ENABLED, false) } - @JvmStatic fun setCallLoggingEnabled(enabled: Boolean): Boolean { return putCommit(CALLS_ENABLED, enabled) } + @JvmStatic + fun getOmniRingEnabled(): Boolean { + return pref.getBoolean(OMNIRING_ENABLED, false) + } + + @JvmStatic + fun setOmniRingEnabled(enabled: Boolean): Boolean { + return putCommit(OMNIRING_ENABLED, enabled) + } + @JvmStatic + fun getCallLoggingEnabled(): Boolean { + return pref.getBoolean(CALLS_ENABLED, false) + } + + @JvmStatic + fun setCallLoggingEnabled(enabled: Boolean): Boolean { + return putCommit(CALLS_ENABLED, enabled) + } @JvmStatic fun getGpsEnabled(): Boolean { return pref.getBoolean(GPS_ENABLED, false) } @JvmStatic fun setGpsEnabled(enabled: Boolean): Boolean { return putCommit(GPS_ENABLED, enabled) } @JvmStatic fun getGyroscopeEnabled(): Boolean { return pref.getBoolean(GYROSCOPE_ENABLED, false) } @@ -384,7 +424,6 @@ object PersistentData { // we want default to be 0 so that checks "is this value less than the current expected value" (eg "did this timer event pass already") - /*########################################################################################### ################################### Text Strings ############################################ ###########################################################################################*/ @@ -424,7 +463,7 @@ object PersistentData { } else if (serverUrl.startsWith("http://")) { "https://" + serverUrl.substring(7, serverUrl.length) } else { - "https://$serverUrl" + "http://$serverUrl" } } diff --git a/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt b/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt index ae875c75..779a36b2 100644 --- a/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt +++ b/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt @@ -21,6 +21,8 @@ object SetDeviceSettings { enablement_change = enablement_change or PersistentData.setTextsEnabled(deviceSettings.getBoolean("texts")) PersistentData.setWifiEnabled(deviceSettings.getBoolean("wifi")) // wifi doesn't have any active state, can ignore. enablement_change = enablement_change or PersistentData.setBluetoothEnabled(deviceSettings.getBoolean("bluetooth")) + enablement_change = + enablement_change or PersistentData.setOmniRingEnabled(deviceSettings.getBoolean("omniring")) enablement_change = enablement_change or PersistentData.setPowerStateEnabled(deviceSettings.getBoolean("power_state")) // any sections in try-catch blocks were added after go-live, so must be caught in case the // app is newer than the server backend. @@ -50,6 +52,9 @@ object SetDeviceSettings { PersistentData.setBluetoothOnDuration(deviceSettings.getLong("bluetooth_on_duration_seconds")) PersistentData.setBluetoothTotalDuration(deviceSettings.getLong("bluetooth_total_duration_seconds")) PersistentData.setBluetoothGlobalOffset(deviceSettings.getLong("bluetooth_global_offset_seconds")) + PersistentData.setBluetoothOnDuration(deviceSettings.getLong("omniring_on_duration_seconds")) + PersistentData.setBluetoothTotalDuration(deviceSettings.getLong("omniring_total_duration_seconds")) + PersistentData.setBluetoothGlobalOffset(deviceSettings.getLong("omniring_global_offset_seconds")) PersistentData.setCheckForNewSurveysFrequency(deviceSettings.getLong("check_for_new_surveys_frequency_seconds")) PersistentData.setCreateNewDataFilesFrequency(deviceSettings.getLong("create_new_data_files_frequency_seconds")) PersistentData.setGpsOffDuration(deviceSettings.getLong("gps_off_duration_seconds")) diff --git a/app/src/main/java/org/beiwe/app/storage/TextFileManager.java b/app/src/main/java/org/beiwe/app/storage/TextFileManager.java index 0c06687b..74ffedf0 100644 --- a/app/src/main/java/org/beiwe/app/storage/TextFileManager.java +++ b/app/src/main/java/org/beiwe/app/storage/TextFileManager.java @@ -9,6 +9,7 @@ import org.beiwe.app.CrashHandler; import org.beiwe.app.listeners.AccelerometerListener; import org.beiwe.app.listeners.AmbientAudioListener; +import org.beiwe.app.listeners.BLEService; import org.beiwe.app.listeners.BluetoothListener; import org.beiwe.app.listeners.CallLogger; import org.beiwe.app.listeners.GPSListener; @@ -58,8 +59,9 @@ public class TextFileManager { private static TextFileManager callLog; private static TextFileManager textsLog; private static TextFileManager bluetoothLog; + private static TextFileManager omniRingLog; private static TextFileManager debugLogFile; - + private static TextFileManager surveyTimings; private static TextFileManager surveyAnswers; private static TextFileManager wifiLog; @@ -117,6 +119,11 @@ public static TextFileManager getBluetoothLogFile () { checkAvailableWithTimeout("bluetoothLog"); return bluetoothLog; } + + public static TextFileManager getOmniRingLog() { + checkAvailableWithTimeout("omniRingLog"); + return omniRingLog; + } public static TextFileManager getWifiLogFile () { checkAvailableWithTimeout("wifiLog"); @@ -182,6 +189,9 @@ private static Boolean checkTextFileAvailable (String thing) { if (thing.equals("bluetoothLog")) { return (bluetoothLog != null); } + if (thing.equals("omniRingLog")) { + return (omniRingLog != null); + } if (thing.equals("wifiLog")) { return (wifiLog != null); } @@ -274,6 +284,9 @@ public static synchronized void initialize (Context appContext) { bluetoothLog = new TextFileManager( appContext, "bluetoothLog", BluetoothListener.header, false, false, true, !PersistentData.getBluetoothEnabled() ); + omniRingLog = new TextFileManager( + appContext, "omniRingLog", BLEService.omniring_header, false, false, true, !PersistentData.getOmniRingEnabled() + ); // Files created on specific events/written to in one go. surveyTimings = new TextFileManager( appContext, "surveyTimings_", SurveyTimingsRecorder.header, false, false, true, false @@ -559,6 +572,7 @@ public static synchronized void makeNewFilesForEverything () { callLog.newFile(); textsLog.newFile(); bluetoothLog.newFile(); + omniRingLog.newFile(); debugLogFile.newFile(); } @@ -605,6 +619,7 @@ public static synchronized String[] getAllUploadableFiles () { files.remove(TextFileManager.getTextsLogFile().fileName); files.remove(TextFileManager.getDebugLogFile().fileName); files.remove(TextFileManager.getBluetoothLogFile().fileName); + files.remove(TextFileManager.getOmniRingLog().fileName); files.remove(AmbientAudioListener.currentlyWritingEncryptedFilename); // These files are only occasionally open, but they may be currently open. If they are, don't upload them From fe8669d781f4535d716b8cf1bf024ae7ab599680 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:14:38 -0500 Subject: [PATCH 06/28] refactor: add omniring timer Signed-off-by: Reyva Babtista (cherry picked from commit c1311ab44f209e50ad4baadaabb8fc931bccc5a5) --- app/src/main/java/org/beiwe/app/PermissionHandler.kt | 2 ++ app/src/main/java/org/beiwe/app/Timer.kt | 4 ++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/app/src/main/java/org/beiwe/app/PermissionHandler.kt b/app/src/main/java/org/beiwe/app/PermissionHandler.kt index f6acca54..b0e6c3ba 100644 --- a/app/src/main/java/org/beiwe/app/PermissionHandler.kt +++ b/app/src/main/java/org/beiwe/app/PermissionHandler.kt @@ -361,6 +361,8 @@ object PermissionHandler { permissions.put("most_recent_ambient_audio_stop", PersistentData.ambientAudioStop) permissions.put("most_recent_bluetooth_start", PersistentData.bluetoothStart) permissions.put("most_recent_bluetooth_stop", PersistentData.bluetoothStop) + permissions.put("most_recent_omniring_start", PersistentData.omniringStart) + permissions.put("most_recent_omniring_stop", PersistentData.omniringStop) permissions.put("most_recent_gps_start", PersistentData.gpsStart) permissions.put("most_recent_gps_stop", PersistentData.gpsStop) permissions.put("most_recent_gyroscope_start", PersistentData.gyroscopeStart) diff --git a/app/src/main/java/org/beiwe/app/Timer.kt b/app/src/main/java/org/beiwe/app/Timer.kt index 033a0387..c7d2c2ec 100644 --- a/app/src/main/java/org/beiwe/app/Timer.kt +++ b/app/src/main/java/org/beiwe/app/Timer.kt @@ -33,6 +33,8 @@ class Timer(mainService: MainService) { lateinit var gyroscopeOnIntent: Intent lateinit var bluetoothOffIntent: Intent lateinit var bluetoothOnIntent: Intent + lateinit var omniRingOffIntent: Intent + lateinit var omniRingOnIntent: Intent lateinit var gpsOffIntent: Intent lateinit var gpsOnIntent: Intent @@ -77,6 +79,8 @@ class Timer(mainService: MainService) { gyroscopeOnIntent = setupIntent(appContext.getString(R.string.turn_gyroscope_on)) bluetoothOffIntent = setupIntent(appContext.getString(R.string.turn_bluetooth_off)) bluetoothOnIntent = setupIntent(appContext.getString(R.string.turn_bluetooth_on)) + omniRingOffIntent = setupIntent(appContext.getString(R.string.turn_omniring_off)) + omniRingOnIntent = setupIntent(appContext.getString(R.string.turn_omniring_on)) gpsOffIntent = setupIntent(appContext.getString(R.string.turn_gps_off)) gpsOnIntent = setupIntent(appContext.getString(R.string.turn_gps_on)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85692bcd..72074215 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -194,6 +194,8 @@ Gyroscope On Bluetooth OFF Bluetooth On + Omniring OFF + Omniring On GPS OFF GPS On Signout From 38ad535847adaf59a212b9f13d68f8f298108320 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:18:16 -0500 Subject: [PATCH 07/28] chore: add network security config, clear text, and service Signed-off-by: Reyva Babtista (cherry picked from commit 2c4d0693743e64f41d85be3ac8347146e1d02b3f) --- app/src/main/AndroidManifest.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index abe1b51c..2c480e50 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,7 +70,8 @@ android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:label="${appName}${appNameSuffix}" - android:theme="@style/AppTheme" > + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true"> @@ -162,6 +163,10 @@ + + Date: Fri, 11 Oct 2024 10:20:34 -0500 Subject: [PATCH 08/28] chore: update gradle Signed-off-by: Reyva Babtista (cherry picked from commit e59bd2a3b91fa312c05a7c288623f7e782d56d9e) --- gradle/wrapper/gradle-wrapper.jar | Bin 53636 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 310 +++++++++++++++-------- gradlew.bat | 88 ++++--- 4 files changed, 250 insertions(+), 153 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 13372aef5e24af05341d49695ee84e5f9b594659..2c3521197d7c4586c843d1d3e9090525f1898cde 100644 GIT binary patch literal 43504 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 TZiJi*F`$tkWLn) literal 53636 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ⅅ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 H-+%o-(+5aQ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c856fecc..09523c0e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Nov 11 20:20:21 EST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip diff --git a/gradlew b/gradlew index 9d82f789..f5feea6d 100755 --- a/gradlew +++ b/gradlew @@ -1,74 +1,130 @@ -#!/usr/bin/env bash +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum -warn ( ) { +warn () { echo "$*" -} +} >&2 -die ( ) { +die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -77,84 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index aec99730..9b42019c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,4 +1,22 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -8,26 +26,30 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -35,54 +57,36 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 4873293874d1256e0673e1a20934672dee78312e Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:29:16 -0500 Subject: [PATCH 09/28] refactor: revert bluetooth listener, remove disable bluetooth call Signed-off-by: Reyva Babtista (cherry picked from commit 86f8b2ce11df215da29b54d7b19680b2f6fb66a5) --- .../beiwe/app/listeners/BluetoothListener.kt | 128 ++++++++++-------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt b/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt index c2cc3311..fdfcded2 100644 --- a/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/BluetoothListener.kt @@ -2,12 +2,14 @@ package org.beiwe.app.listeners import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothAdapter.LeScanCallback import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import org.beiwe.app.storage.EncryptionEngine import org.beiwe.app.storage.PersistentData +import org.beiwe.app.storage.TextFileManager import java.util.Date // import android.content.pm.PackageManager; @@ -46,14 +48,10 @@ class BluetoothListener : BroadcastReceiver() { fun getScanActive(): Boolean { return scanActive } - - private const val CLIENT_CHARACTERISTIC_CONFIG = "00002902-0000-1000-8000-00805f9b34fb" - private const val TAG = "BluetoothListener" } // the access to the bluetooth adaptor private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() - private var bluetoothService: BLEService? = null // bluetoothExists can be set to false if the device does not meet our needs. private var bluetoothExists: Boolean? = null @@ -144,6 +142,7 @@ class BluetoothListener : BroadcastReceiver() { } else { enableBluetooth() } + TextFileManager.getBluetoothLogFile().newFile() } /** Intelligently and safely disables bluetooth. @@ -158,21 +157,37 @@ class BluetoothListener : BroadcastReceiver() { } Log.i("BluetoothListener", "disable BLE scan.") scanActive = false -// bluetoothService?.bluetoothLeScanner?.stopScan(scanCallback) + bluetoothAdapter!!.stopLeScan(bluetoothCallback) // disableBluetooth() } /** Intelligently ACTUALLY STARTS a Bluetooth LE scan. * If Bluetooth is available, start scanning. Makes verbose logging statements */ + @Suppress("deprecation") + @SuppressLint("NewApi") private fun tryScanning() { + Log.i("bluetooth", "starting a scan: " + scanActive) if (isBluetoothEnabled) { - Log.i("bluetooth", "starting a scan: $scanActive") -// bluetoothService?.bluetoothLeScanner?.startScan(scanCallback) + if (bluetoothAdapter!!.startLeScan(bluetoothCallback)) { /*Log.d("bluetooth", "bluetooth LE scan started successfully.");*/ + } else { + Log.w("bluetooth", "bluetooth LE scan NOT started successfully.") + } } else { Log.w("bluetooth", "bluetooth could not be enabled?") } } + /** LeScanCallback is code that is run when a Bluetooth LE scan returns some data. + * We take the returned data and log it. */ + @SuppressLint("NewApi") + private val bluetoothCallback = LeScanCallback { device, rssi, scanRecord -> + TextFileManager.getBluetoothLogFile().writeEncrypted( + System.currentTimeMillis() + .toString() + "," + EncryptionEngine.hashMAC(device.toString()) + "," + rssi + ) + // Log.i("Bluetooth", System.currentTimeMillis() + "," + device.toString() + ", " + rssi ) + } + /*#################################################################################### ################# the onReceive Stack for Bluetooth state messages ################### @@ -185,58 +200,57 @@ class BluetoothListener : BroadcastReceiver() { * Additionally, if a Bluetooth On notification comes in AND the scanActive variable is set to TRUE * we start a Bluetooth LE scan. */ override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action == BluetoothAdapter.ACTION_STATE_CHANGED) { + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.ERROR) { + Log.e("bluetooth", "BLUETOOTH ADAPTOR ERROR?") + } else if (state == BluetoothAdapter.STATE_ON) { + // Log.i("bluetooth", "state change: on" ); + if (scanActive) + enableBLEScan() + + } else if (state == BluetoothAdapter.STATE_TURNING_ON) { + // Log.i("bluetooth", "state change: turning on"); + if (!internalBluetoothState) + externalBluetoothState = true + + } else if (state == BluetoothAdapter.STATE_TURNING_OFF) { + // Log.i("bluetooth", "state change: turning off"); + if (internalBluetoothState) + externalBluetoothState = false - when (intent.action) { - BLEService.ACTION_GATT_CONNECTED -> { - Log.d(TAG, "gatt connected") - } - - BLEService.ACTION_GATT_DISCONNECTED -> { - Log.d(TAG, "gatt disconnected") - } - - BLEService.ACTION_GATT_SERVICES_DISCOVERED -> { - Log.d(TAG, "gatt services discovered") - displayGattServices(bluetoothService?.getSupportedGattServices()) - } - - BLEService.ACTION_DATA_AVAILABLE -> { - intent.extras?.getString(BLEService.EXTRA_DATA)?.let { - Log.d(TAG, "gatt data $it") - } - } - - BluetoothAdapter.ACTION_STATE_CHANGED -> { - val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) - if (state == BluetoothAdapter.ERROR) { - Log.e("bluetooth", "BLUETOOTH ADAPTOR ERROR?") - } else if (state == BluetoothAdapter.STATE_ON) { - val gattServiceIntent = Intent(context, BLEService::class.java) - context.startService(gattServiceIntent) - - if (scanActive) - enableBLEScan() - - } else if (state == BluetoothAdapter.STATE_TURNING_ON) { - if (!internalBluetoothState) - externalBluetoothState = true - - } else if (state == BluetoothAdapter.STATE_TURNING_OFF) { - if (internalBluetoothState) - externalBluetoothState = false - - } } } } - private fun displayGattServices(gattServices: List?) { - gattServices?.forEach { service -> - Log.d(TAG, "gatt service: ${service.uuid}") - service.characteristics?.forEach { characteristic -> - Log.d(TAG, "gatt service characteristic: ${characteristic.uuid}") - bluetoothService?.readCharacteristic(characteristic) - } - } - } + /*############################################################################### + ############################# Debugging Code #################################### + ###############################################################################*/ + // val state: String + // get() { + // if (!bluetoothExists!!) + // return "does not exist." + // val state = bluetoothAdapter!!.state + // // STATE_OFF, STATE_TURNING_ON, STATE_ON, STATE_TURNING_OFF + // if (state == BluetoothAdapter.STATE_OFF) + // return "off" + // else if (state == BluetoothAdapter.STATE_TURNING_ON) + // return "turning on" + // else if (state == BluetoothAdapter.STATE_ON) + // return "on" + // else if (state == BluetoothAdapter.STATE_TURNING_OFF) + // return "turning off" + // else + // return "getstate is broken, value was $state" + // } + // + // fun bluetoothInfo() { + // Log.i("bluetooth", "bluetooth existence: " + bluetoothExists.toString()) + // Log.i("bluetooth", "bluetooth enabled: " + isBluetoothEnabled) + // // Log.i("bluetooth", "bluetooth address: " + bluetoothAdapter!!.address) + // Log.i("bluetooth", "bluetooth state: " + state) + // Log.i("bluetooth", "bluetooth scan mode: " + bluetoothAdapter.scanMode) + // Log.i("bluetooth", "bluetooth bonded devices:" + bluetoothAdapter.bondedDevices) + // } } \ No newline at end of file From 8fa83745254fef84e0f863bbab34cd9aa170a803 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:30:08 -0500 Subject: [PATCH 10/28] refactor: change class name Signed-off-by: Reyva Babtista (cherry picked from commit 1ee3a59c7a151bfbea05a1fb4aa6c007ed537eb7) --- .../listeners/{BLEService.kt => OmniringService.kt} | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) rename app/src/main/java/org/beiwe/app/listeners/{BLEService.kt => OmniringService.kt} (96%) diff --git a/app/src/main/java/org/beiwe/app/listeners/BLEService.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringService.kt similarity index 96% rename from app/src/main/java/org/beiwe/app/listeners/BLEService.kt rename to app/src/main/java/org/beiwe/app/listeners/OmniringService.kt index ff647f13..a5164809 100644 --- a/app/src/main/java/org/beiwe/app/listeners/BLEService.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringService.kt @@ -19,10 +19,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID -class BLEService : Service() { - // private val bluetoothManager: BluetoothManager = -// getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager -// private val binder = LocalBinder() +class OmniringService : Service() { private var bluetoothAdapter: BluetoothAdapter? = null private var bluetoothGatt: BluetoothGatt? = null private var connectionState = STATE_DISCONNECTED @@ -54,12 +51,6 @@ class BLEService : Service() { return null } -// inner class LocalBinder : Binder() { -// fun getService(): BLEService { -// return this@BLEService -// } -// } - fun initialize(): Boolean { bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() if (bluetoothAdapter == null) { From 2a54ee83e57df123416fe79ff628c2c6323ae590 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 10:31:19 -0500 Subject: [PATCH 11/28] refactor: change class name Signed-off-by: Reyva Babtista (cherry picked from commit a4f404df0044977aa0d872a9ffd4e44d16112ce3) --- .../app/listeners/{OmniringService.kt => OmniringListener.kt} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename app/src/main/java/org/beiwe/app/listeners/{OmniringService.kt => OmniringListener.kt} (99%) diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringService.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt similarity index 99% rename from app/src/main/java/org/beiwe/app/listeners/OmniringService.kt rename to app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index a5164809..bb55fec8 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringService.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -19,14 +19,14 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID -class OmniringService : Service() { +class OmniringListener : Service() { private var bluetoothAdapter: BluetoothAdapter? = null private var bluetoothGatt: BluetoothGatt? = null private var connectionState = STATE_DISCONNECTED var bluetoothLeScanner: BluetoothLeScanner? = null companion object { - private const val TAG = "BLEService" + private const val TAG = "OmniringListener" const val ACTION_GATT_CONNECTED = "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_CONNECTED" const val ACTION_GATT_DISCONNECTED = From 391d7bc0c4c5dd151270ff64f1bcaff1085c3dc2 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 11:14:41 -0500 Subject: [PATCH 12/28] refactor: data clean up Signed-off-by: Reyva Babtista (cherry picked from commit c20bf4d5981db06ced639dd4a83a3dd9ec47231f) --- app/src/main/AndroidManifest.xml | 2 +- .../beiwe/app/listeners/OmniringListener.kt | 266 ++++++++---------- .../beiwe/app/storage/TextFileManager.java | 4 +- 3 files changed, 122 insertions(+), 150 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2c480e50..d35ba681 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -164,7 +164,7 @@ diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index bb55fec8..6942daf4 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -7,19 +7,21 @@ import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.content.Intent import android.os.IBinder import android.util.Log -import org.beiwe.app.storage.EncryptionEngine +import org.beiwe.app.PermissionHandler import org.beiwe.app.storage.TextFileManager import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID class OmniringListener : Service() { + private var bluetoothManager: BluetoothManager = getSystemService(BluetoothManager::class.java) private var bluetoothAdapter: BluetoothAdapter? = null private var bluetoothGatt: BluetoothGatt? = null private var connectionState = STATE_DISCONNECTED @@ -27,16 +29,8 @@ class OmniringListener : Service() { companion object { private const val TAG = "OmniringListener" - const val ACTION_GATT_CONNECTED = - "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_CONNECTED" - const val ACTION_GATT_DISCONNECTED = - "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_DISCONNECTED" - const val ACTION_GATT_SERVICES_DISCOVERED = - "dev.rbabtista.kmm_phenotyping.external.ACTION_GATT_SERVICES_DISCOVERED" - const val ACTION_DATA_AVAILABLE = - "dev.rbabtista.kmm_phenotyping.external.ACTION_DATA_AVAILABLE" - const val EXTRA_DATA = "dev.rbabtista.kmm_phenotyping.external.EXTRA_DATA" private const val CLIENT_CHARACTERISTIC_CONFIG = "00002902-0000-1000-8000-00805f9b34fb" + private const val OMNIRING_DATA_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" private const val STATE_DISCONNECTED = 0 private const val STATE_CONNECTED = 2 @@ -51,68 +45,111 @@ class OmniringListener : Service() { return null } - fun initialize(): Boolean { - bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() - if (bluetoothAdapter == null) { - Log.e(TAG, "Unable to obtain a BluetoothAdapter.") - return false + fun unpackFByteArray(byteArray: ByteArray): Float { + val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN) + return buffer.float + } + + fun decodeByteData(byteData: ByteArray): List { + val floatArray = mutableListOf() + for (i in byteData.indices step 4) { + val tmpFloat = unpackFByteArray(byteData.copyOfRange(i, i + 4)) + floatArray.add(tmpFloat) + } + return floatArray + } + + private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } + + fun readCharacteristic(characteristic: BluetoothGattCharacteristic) { + bluetoothGatt?.let { gatt -> + gatt.readCharacteristic(characteristic) + } ?: run { + Log.w(TAG, "BluetoothGatt not initialized") + return + } + } + + // Disable notifications + fun disableNotification( + serviceUUID: String, + characteristicUUID: String + ) { + val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) + val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) + if (characteristic != null) { + // Disable notifications + bluetoothGatt?.setCharacteristicNotification(characteristic, false) + + // Configure the descriptor for notifications + val descriptor = + characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) + descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + bluetoothGatt?.writeDescriptor(descriptor) } - bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner - TextFileManager.getBluetoothLogFile().newFile() - return true } - private fun broadcastUpdate(action: String) { - val intent = Intent(action) - sendBroadcast(intent) + // Enable notifications + fun enableNotification( + serviceUUID: String, + characteristicUUID: String + ) { + val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) + val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) + if (characteristic != null) { + // Enable notifications + bluetoothGatt?.setCharacteristicNotification(characteristic, true) + +// // Configure the descriptor for notifications +// val descriptor = +// characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) +// descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE +// bluetoothGatt?.writeDescriptor(descriptor) + } } - private fun broadcastUpdate(action: String, characteristic: BluetoothGattCharacteristic) { - val intent = Intent(action) + private fun getSupportedGattServices(): List? { + return if (bluetoothGatt == null) null else bluetoothGatt?.services + } - // For all other profiles, writes the data formatted in HEX. - val data: ByteArray? = characteristic.value - if (data?.isNotEmpty() == true) { - val hexString: String = data.joinToString(separator = " ") { - String.format("%02X", it) + private fun subscribeToNotifications() { + val gattServices = getSupportedGattServices() + gattServices?.forEach { service -> + Log.d(TAG, "subscribeToNotifications: service found ${service.uuid}") + service.characteristics.forEach { characteristic -> + Log.d(TAG, "subscribeToNotifications: characteristic found ${characteristic.uuid}") + + if (characteristic.uuid.toString() == OMNIRING_DATA_CHARACTERISTIC_UUID) { + Log.d( + TAG, + "subscribeToNotifications: omniring data characteristic found, enabling notifications" + ) + enableNotification( + serviceUUID = service.uuid.toString(), + characteristicUUID = characteristic.uuid.toString() + ) + } } - intent.putExtra(EXTRA_DATA, "${characteristic.uuid} || $data\n$hexString") } - sendBroadcast(intent) } private val bluetoothGattCallback = object : BluetoothGattCallback() { + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + super.onServicesDiscovered(gatt, status) + } + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "onConnectionStateChange: connected to omniring ${gatt.device.name}") + Log.d(TAG, "onConnectionStateChange: now discovering services..") connectionState = STATE_CONNECTED - broadcastUpdate(ACTION_GATT_CONNECTED) bluetoothGatt?.discoverServices() } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d( + TAG, + "onConnectionStateChange: disconnected from omniring ${gatt.device.name}" + ) connectionState = STATE_DISCONNECTED - broadcastUpdate(ACTION_GATT_DISCONNECTED) - } - } - - override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { - super.onServicesDiscovered(gatt, status) - if (status == BluetoothGatt.GATT_SUCCESS) { - broadcastUpdate(ACTION_GATT_SERVICES_DISCOVERED) - } - - enableNotification( - serviceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e", - characteristicUUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" - ) - } - - override fun onCharacteristicRead( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - value: ByteArray, - status: Int - ) { - if (status == BluetoothGatt.GATT_SUCCESS) { - broadcastUpdate(ACTION_DATA_AVAILABLE, characteristic) } } @@ -120,10 +157,6 @@ class OmniringListener : Service() { gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic? ) { - Log.d( - TAG, - "characteristic changed: ${decodeByteData(characteristic?.value ?: byteArrayOf())}" - ) TextFileManager.getOmniRingLog().writeEncrypted( System.currentTimeMillis().toString() + "," + decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") @@ -135,108 +168,35 @@ class OmniringListener : Service() { descriptor: BluetoothGattDescriptor?, status: Int ) { - Log.d(TAG, "descriptor written: ${descriptor?.value?.toHexString()}") + Log.d(TAG, "onDescriptorWrite: ${descriptor?.value?.toHexString()}") } } - fun unpackFByteArray(byteArray: ByteArray): Float { - val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN) - return buffer.float - } - - fun decodeByteData(byteData: ByteArray): List { - val floatArray = mutableListOf() - for (i in byteData.indices step 4) { - val tmpFloat = unpackFByteArray(byteData.copyOfRange(i, i + 4)) - floatArray.add(tmpFloat) - } - return floatArray - } - - private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } - - fun getSupportedGattServices(): List? { - return if (bluetoothGatt == null) null else bluetoothGatt?.services - } - fun connect(address: String): Boolean { bluetoothAdapter?.let { adapter -> try { val device = adapter.getRemoteDevice(address) - bluetoothGatt = device.connectGatt(this, false, bluetoothGattCallback) + bluetoothGatt = + device.connectGatt(this@OmniringListener, false, bluetoothGattCallback) return true } catch (e: IllegalArgumentException) { - Log.e(TAG, "Invalid address.") + Log.e(TAG, "failed to connect to omniring ${e.message}") return false } } ?: run { - Log.e(TAG, "BluetoothAdapter not initialized.") + Log.e(TAG, "connect: bluetooth adapter not initialized") return false } } - fun readCharacteristic(characteristic: BluetoothGattCharacteristic) { - bluetoothGatt?.let { gatt -> - gatt.readCharacteristic(characteristic) - } ?: run { - Log.w(TAG, "BluetoothGatt not initialized") - return - } - } - - // Enable notifications - fun enableNotification( - serviceUUID: String, - characteristicUUID: String - ) { - val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) - val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) - if (characteristic != null) { - // Enable notifications - bluetoothGatt?.setCharacteristicNotification(characteristic, true) - - // Configure the descriptor for notifications - val descriptor = - characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) - descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - bluetoothGatt?.writeDescriptor(descriptor) - } - } - - // Disable notifications - fun disableNotification( - serviceUUID: String, - characteristicUUID: String - ) { - val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) - val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) - if (characteristic != null) { - // Disable notifications - bluetoothGatt?.setCharacteristicNotification(characteristic, false) - - // Configure the descriptor for notifications - val descriptor = - characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) - descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE - bluetoothGatt?.writeDescriptor(descriptor) - } - } - private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: android.bluetooth.le.ScanResult) { super.onScanResult(callbackType, result) val device = result.device - val rssi = result.rssi -// Log.i( -// "Bluetooth", -// System.currentTimeMillis().toString() + "," + device + ", " + rssi -// ) - TextFileManager.getBluetoothLogFile().writeEncrypted( - System.currentTimeMillis() - .toString() + "," + EncryptionEngine.hashMAC(device.toString()) + "," + rssi - ) - if (device.name == "PPG_Ring#1") { - Log.d(TAG, "onScanResult: found device, connecting") + if ( + PermissionHandler.checkBluetoothPermissions(this@OmniringListener) && + device.name.startsWith("PPG_Ring") + ) { TextFileManager.getOmniRingLog().newFile() if (device.bondState != BluetoothAdapter.STATE_CONNECTED) connect(device.address) @@ -249,21 +209,33 @@ class OmniringListener : Service() { } } + fun initialize(): Boolean { + Log.d(TAG, "initialize: init omniring") + bluetoothAdapter = bluetoothManager.adapter + if (bluetoothAdapter == null) { + Log.e(TAG, "Unable to obtain a BluetoothAdapter.") + return false + } + bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner + return true + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { initialize() - bluetoothLeScanner?.startScan(scanCallback) + if (PermissionHandler.checkBluetoothPermissions(this)) + bluetoothLeScanner?.startScan(scanCallback) return START_STICKY } - override fun onDestroy() { - close() - super.onDestroy() - } - private fun close() { bluetoothGatt?.let { gatt -> gatt.close() bluetoothGatt = null } } + + override fun onDestroy() { + close() + super.onDestroy() + } } \ No newline at end of file diff --git a/app/src/main/java/org/beiwe/app/storage/TextFileManager.java b/app/src/main/java/org/beiwe/app/storage/TextFileManager.java index 74ffedf0..36360e54 100644 --- a/app/src/main/java/org/beiwe/app/storage/TextFileManager.java +++ b/app/src/main/java/org/beiwe/app/storage/TextFileManager.java @@ -9,11 +9,11 @@ import org.beiwe.app.CrashHandler; import org.beiwe.app.listeners.AccelerometerListener; import org.beiwe.app.listeners.AmbientAudioListener; -import org.beiwe.app.listeners.BLEService; import org.beiwe.app.listeners.BluetoothListener; import org.beiwe.app.listeners.CallLogger; import org.beiwe.app.listeners.GPSListener; import org.beiwe.app.listeners.GyroscopeListener; +import org.beiwe.app.listeners.OmniringListener; import org.beiwe.app.listeners.PowerStateListener; import org.beiwe.app.listeners.SmsSentLogger; import org.beiwe.app.listeners.WifiListener; @@ -285,7 +285,7 @@ public static synchronized void initialize (Context appContext) { appContext, "bluetoothLog", BluetoothListener.header, false, false, true, !PersistentData.getBluetoothEnabled() ); omniRingLog = new TextFileManager( - appContext, "omniRingLog", BLEService.omniring_header, false, false, true, !PersistentData.getOmniRingEnabled() + appContext, "omniRingLog", OmniringListener.omniring_header, false, false, true, !PersistentData.getOmniRingEnabled() ); // Files created on specific events/written to in one go. surveyTimings = new TextFileManager( From c31e112bea00837485200928dfbd1eeb810f676e Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 11:22:33 -0500 Subject: [PATCH 13/28] refactor: logic clean up Signed-off-by: Reyva Babtista (cherry picked from commit 450da3db1c10740ce99c3ffa47e51085bbb54c98) --- .../beiwe/app/listeners/OmniringListener.kt | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index 6942daf4..41daa0cf 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -25,7 +25,9 @@ class OmniringListener : Service() { private var bluetoothAdapter: BluetoothAdapter? = null private var bluetoothGatt: BluetoothGatt? = null private var connectionState = STATE_DISCONNECTED - var bluetoothLeScanner: BluetoothLeScanner? = null + private var bluetoothLeScanner: BluetoothLeScanner? = null + private var OMNIRING_DATA_SERVICE_UUID = "" + var collecting = false companion object { private const val TAG = "OmniringListener" @@ -45,12 +47,12 @@ class OmniringListener : Service() { return null } - fun unpackFByteArray(byteArray: ByteArray): Float { + private fun unpackFByteArray(byteArray: ByteArray): Float { val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN) return buffer.float } - fun decodeByteData(byteData: ByteArray): List { + private fun decodeByteData(byteData: ByteArray): List { val floatArray = mutableListOf() for (i in byteData.indices step 4) { val tmpFloat = unpackFByteArray(byteData.copyOfRange(i, i + 4)) @@ -61,17 +63,8 @@ class OmniringListener : Service() { private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } - fun readCharacteristic(characteristic: BluetoothGattCharacteristic) { - bluetoothGatt?.let { gatt -> - gatt.readCharacteristic(characteristic) - } ?: run { - Log.w(TAG, "BluetoothGatt not initialized") - return - } - } - // Disable notifications - fun disableNotification( + private fun disableNotification( serviceUUID: String, characteristicUUID: String ) { @@ -90,7 +83,7 @@ class OmniringListener : Service() { } // Enable notifications - fun enableNotification( + private fun enableNotification( serviceUUID: String, characteristicUUID: String ) { @@ -120,13 +113,14 @@ class OmniringListener : Service() { Log.d(TAG, "subscribeToNotifications: characteristic found ${characteristic.uuid}") if (characteristic.uuid.toString() == OMNIRING_DATA_CHARACTERISTIC_UUID) { + OMNIRING_DATA_SERVICE_UUID = service.uuid.toString() Log.d( TAG, "subscribeToNotifications: omniring data characteristic found, enabling notifications" ) enableNotification( - serviceUUID = service.uuid.toString(), - characteristicUUID = characteristic.uuid.toString() + serviceUUID = OMNIRING_DATA_SERVICE_UUID, + characteristicUUID = OMNIRING_DATA_CHARACTERISTIC_UUID ) } } @@ -136,6 +130,9 @@ class OmniringListener : Service() { private val bluetoothGattCallback = object : BluetoothGattCallback() { override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) + if (collecting) { + subscribeToNotifications() + } } override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { @@ -172,7 +169,7 @@ class OmniringListener : Service() { } } - fun connect(address: String): Boolean { + private fun connect(address: String): Boolean { bluetoothAdapter?.let { adapter -> try { val device = adapter.getRemoteDevice(address) @@ -209,7 +206,7 @@ class OmniringListener : Service() { } } - fun initialize(): Boolean { + private fun initialize(): Boolean { Log.d(TAG, "initialize: init omniring") bluetoothAdapter = bluetoothManager.adapter if (bluetoothAdapter == null) { From 318d764e93510739277121ef4288402a59271461 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 15:37:01 -0500 Subject: [PATCH 14/28] fix: on off omniring logic Signed-off-by: Reyva Babtista (cherry picked from commit 16354187ddb735c818db8c2392cfd72394985a45) --- .../beiwe/app/listeners/OmniringListener.kt | 99 ++++++++++--------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index 41daa0cf..cc249fca 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -2,6 +2,7 @@ package org.beiwe.app.listeners import android.app.Service import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic @@ -12,6 +13,7 @@ import android.bluetooth.BluetoothProfile import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.content.Intent +import android.content.pm.PackageManager import android.os.IBinder import android.util.Log import org.beiwe.app.PermissionHandler @@ -26,8 +28,10 @@ class OmniringListener : Service() { private var bluetoothGatt: BluetoothGatt? = null private var connectionState = STATE_DISCONNECTED private var bluetoothLeScanner: BluetoothLeScanner? = null - private var OMNIRING_DATA_SERVICE_UUID = "" - var collecting = false + private var omniringDevice: BluetoothDevice? = null + private var omniringDataCharacteristic: BluetoothGattCharacteristic? = null + var running = false + var exists: Boolean = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) companion object { private const val TAG = "OmniringListener" @@ -63,49 +67,37 @@ class OmniringListener : Service() { private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) } - // Disable notifications - private fun disableNotification( - serviceUUID: String, - characteristicUUID: String - ) { - val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) - val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) - if (characteristic != null) { - // Disable notifications - bluetoothGatt?.setCharacteristicNotification(characteristic, false) - - // Configure the descriptor for notifications - val descriptor = - characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) - descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE - bluetoothGatt?.writeDescriptor(descriptor) - } + // Disable notificationsV + private fun disableNotification() = omniringDataCharacteristic?.let { characteristic -> + // Disable notifications + bluetoothGatt?.setCharacteristicNotification(characteristic, false) + + // Configure the descriptor for notifications + val descriptor = + characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) + descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + bluetoothGatt?.writeDescriptor(descriptor) + running = false } // Enable notifications - private fun enableNotification( - serviceUUID: String, - characteristicUUID: String - ) { - val service = bluetoothGatt?.getService(UUID.fromString(serviceUUID)) - val characteristic = service?.getCharacteristic(UUID.fromString(characteristicUUID)) - if (characteristic != null) { - // Enable notifications - bluetoothGatt?.setCharacteristicNotification(characteristic, true) - -// // Configure the descriptor for notifications -// val descriptor = -// characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) -// descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE -// bluetoothGatt?.writeDescriptor(descriptor) - } + private fun enableNotification() = omniringDataCharacteristic?.let { characteristic -> + // Enable notifications + bluetoothGatt?.setCharacteristicNotification(characteristic, true) + + // Configure the descriptor for notifications + val descriptor = + characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + bluetoothGatt?.writeDescriptor(descriptor) + running = true } private fun getSupportedGattServices(): List? { return if (bluetoothGatt == null) null else bluetoothGatt?.services } - private fun subscribeToNotifications() { + private fun findOmniringCharacteristic() { val gattServices = getSupportedGattServices() gattServices?.forEach { service -> Log.d(TAG, "subscribeToNotifications: service found ${service.uuid}") @@ -113,15 +105,11 @@ class OmniringListener : Service() { Log.d(TAG, "subscribeToNotifications: characteristic found ${characteristic.uuid}") if (characteristic.uuid.toString() == OMNIRING_DATA_CHARACTERISTIC_UUID) { - OMNIRING_DATA_SERVICE_UUID = service.uuid.toString() + omniringDataCharacteristic = characteristic Log.d( TAG, "subscribeToNotifications: omniring data characteristic found, enabling notifications" ) - enableNotification( - serviceUUID = OMNIRING_DATA_SERVICE_UUID, - characteristicUUID = OMNIRING_DATA_CHARACTERISTIC_UUID - ) } } } @@ -130,8 +118,8 @@ class OmniringListener : Service() { private val bluetoothGattCallback = object : BluetoothGattCallback() { override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) - if (collecting) { - subscribeToNotifications() + if (running) { + findOmniringCharacteristic() } } @@ -195,6 +183,7 @@ class OmniringListener : Service() { device.name.startsWith("PPG_Ring") ) { TextFileManager.getOmniRingLog().newFile() + omniringDevice = device if (device.bondState != BluetoothAdapter.STATE_CONNECTED) connect(device.address) } @@ -219,11 +208,31 @@ class OmniringListener : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { initialize() - if (PermissionHandler.checkBluetoothPermissions(this)) - bluetoothLeScanner?.startScan(scanCallback) return START_STICKY } + val omniring_on_action: () -> Unit = { + if (bluetoothAdapter?.isEnabled == false) { + Log.e(TAG, "Bluetooth is disabled.") + } else { + if (omniringDevice == null) { + bluetoothLeScanner?.startScan(scanCallback) + } + + if (omniringDevice != null && connectionState == STATE_DISCONNECTED) { + connect(omniringDevice?.address ?: "") + } + + if (omniringDataCharacteristic != null) { + enableNotification() + } + } + } + + val omniring_off_action: () -> Unit = { + disableNotification() + } + private fun close() { bluetoothGatt?.let { gatt -> gatt.close() From 4899e1c551e306cd8b422de3ac16e4ca9792039c Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 15:37:11 -0500 Subject: [PATCH 15/28] fix: on off omniring logic Signed-off-by: Reyva Babtista (cherry picked from commit 8832680c02dff6110771b8b94d740004c35875c4) --- .../main/java/org/beiwe/app/MainService.kt | 48 +++++++++++++++++-- .../org/beiwe/app/storage/PersistentData.kt | 29 +++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index 2c670fe9..efe4a390 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -23,6 +23,17 @@ import org.beiwe.app.PermissionHandler.confirmBluetooth import org.beiwe.app.PermissionHandler.confirmCallLogging import org.beiwe.app.PermissionHandler.confirmTexts import org.beiwe.app.listeners.* +import org.beiwe.app.listeners.AccelerometerListener +import org.beiwe.app.listeners.AmbientAudioListener +import org.beiwe.app.listeners.BluetoothListener +import org.beiwe.app.listeners.CallLogger +import org.beiwe.app.listeners.GPSListener +import org.beiwe.app.listeners.GyroscopeListener +import org.beiwe.app.listeners.MMSSentLogger +import org.beiwe.app.listeners.OmniringListener +import org.beiwe.app.listeners.PowerStateListener +import org.beiwe.app.listeners.SmsSentLogger +import org.beiwe.app.listeners.WifiListener import org.beiwe.app.networking.PostRequest import org.beiwe.app.networking.SurveyDownloader import org.beiwe.app.storage.* @@ -64,6 +75,7 @@ class MainService : Service() { var accelerometerListener: AccelerometerListener? = null var gyroscopeListener: GyroscopeListener? = null var bluetoothListener: BluetoothListener? = null + var omniringListener: OmniringListener? = null // these assets don't require android assets, they can go in the common init. val background_handlerThread = HandlerThread("background_handler_thread") @@ -144,7 +156,7 @@ class MainService : Service() { // Bluetooth, wifi, gps, calls, and texts need permissions if (confirmBluetooth(applicationContext)) - startBluetooth() + initializeBluetoothAndOmniring() if (confirmTexts(applicationContext)) { startSmsSentLogger() @@ -182,7 +194,7 @@ class MainService : Service() { * Bluetooth has several checks to make sure that it actually exists on the device with the * capabilities we need. Checking for Bluetooth LE is necessary because it is an optional * extension to Bluetooth 4.0. */ - fun startBluetooth() { + fun initializeBluetoothAndOmniring() { // Note: the Bluetooth listener is a BroadcastReceiver, which means it must have a 0-argument // constructor in order for android to instantiate it on broadcast receipts. The following // check must be made, but it requires a Context that we cannot pass into the @@ -190,6 +202,7 @@ class MainService : Service() { if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) && PersistentData.getBluetoothEnabled()) { bluetoothListener = BluetoothListener() + omniringListener = OmniringListener() if (bluetoothListener!!.isBluetoothEnabled) { val intent_filter = IntentFilter("android.bluetooth.adapter.action.STATE_CHANGED") registerReceiver(bluetoothListener, intent_filter) @@ -538,6 +551,7 @@ class MainService : Service() { do_new_files_check(now) // always 10s of ms (30-70ms) do_heartbeat_check(now) // always 10s of ms (30-70ms) accelerometer_logic(now) + omniring_logic(now) gyro_logic(now) // on action ~20-50ms, off action 10-20ms gps_logic(now) // on acction <10-20ms, off action ~2ms (yes two) ambient_audio_logic(now) // asynchronous when stopping because it has to encrypt @@ -552,6 +566,28 @@ class MainService : Service() { return now } + fun omniring_logic(now: Long) { + if (!PersistentData.getOmniRingEnabled() || omniringListener?.exists == false) + return + + val on_string = getString(R.string.turn_omniring_on) + val off_string = getString(R.string.turn_omniring_off) + val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) + val should_turn_off_at = most_recent_on + PersistentData.getOmniringOnDuration() + val should_turn_on_again_at = + should_turn_off_at + PersistentData.getAccelerometerOffDuration() + do_an_on_off_session_check( + now, + omniringListener!!.running, + should_turn_off_at, + should_turn_on_again_at, + on_string, + off_string, + omniringListener!!.omniring_on_action, + omniringListener!!.omniring_off_action + ) + } + fun accelerometer_logic(now: Long) { // accelerometer may not exist, or be disabled for the study, but it does not require permissions. if (!PersistentData.getAccelerometerEnabled() || !accelerometerListener!!.exists) @@ -714,7 +750,12 @@ class MainService : Service() { val download_device_settings_action = { SetDeviceSettings.dispatchUpdateDeviceSettings() } - do_an_event_session_check(now, event_string, DEVICE_SETTINGS_UPDATE_PERIODICITY, download_device_settings_action) + do_an_event_session_check( + now, + event_string, + DEVICE_SETTINGS_UPDATE_PERIODICITY, + download_device_settings_action + ) } /** Checks for the current expected state for survey notifications, and the app state for @@ -908,6 +949,7 @@ class MainService : Service() { filter.addAction(applicationContext.getString(R.string.turn_gyroscope_off)) filter.addAction(applicationContext.getString(R.string.turn_bluetooth_on)) filter.addAction(applicationContext.getString(R.string.turn_bluetooth_off)) + filter.addAction(applicationContext.getString(R.string.turn_omniring_on)) filter.addAction(applicationContext.getString(R.string.turn_gps_on)) filter.addAction(applicationContext.getString(R.string.turn_gps_off)) filter.addAction(applicationContext.getString(R.string.signout_intent)) diff --git a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt index 5ccf92c8..1133c12d 100644 --- a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt +++ b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt @@ -395,6 +395,35 @@ object PersistentData { @JvmStatic fun setBluetoothOnDuration(seconds: Long) { putCommit(BLUETOOTH_ON_SECONDS, seconds) } @JvmStatic fun getBluetoothTotalDuration(): Long { return 1000L * pref.getLong(BLUETOOTH_TOTAL_SECONDS, (5 * 60).toLong()) } @JvmStatic fun setBluetoothTotalDuration(seconds: Long) { putCommit(BLUETOOTH_TOTAL_SECONDS, seconds) } + @JvmStatic + fun getOmniringGlobalOffset(): Long { + return 1000L * pref.getLong(OMNIRING_GLOBAL_OFFSET_SECONDS, (0 * 60).toLong()) + } + + @JvmStatic + fun setOmniringGlobalOffset(seconds: Long) { + putCommit(OMNIRING_GLOBAL_OFFSET_SECONDS, seconds) + } + + @JvmStatic + fun getOmniringOnDuration(): Long { + return 1000L * pref.getLong(OMNIRING_ON_SECONDS, (1 * 60).toLong()) + } + + @JvmStatic + fun setOmniringOnDuration(seconds: Long) { + putCommit(OMNIRING_ON_SECONDS, seconds) + } + + @JvmStatic + fun getOmniringTotalDuration(): Long { + return 1000L * pref.getLong(OMNIRING_TOTAL_SECONDS, (5 * 60).toLong()) + } + + @JvmStatic + fun setOmniringTotalDuration(seconds: Long) { + putCommit(OMNIRING_TOTAL_SECONDS, seconds) + } @JvmStatic fun getCheckForNewSurveysFrequency(): Long { return 1000L * pref.getLong(CHECK_FOR_NEW_SURVEYS_FREQUENCY_SECONDS, (24 * 60 * 60).toLong()) } @JvmStatic fun setCheckForNewSurveysFrequency(seconds: Long) { putCommit(CHECK_FOR_NEW_SURVEYS_FREQUENCY_SECONDS, seconds) } @JvmStatic fun getCreateNewDataFilesFrequency(): Long { return 1000L * pref.getLong(CREATE_NEW_DATA_FILES_FREQUENCY_SECONDS, (15 * 60).toLong()) } From 4ba9f5568e3282d9e30a93361680f11f3f7ddaaa Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Fri, 11 Oct 2024 15:39:56 -0500 Subject: [PATCH 16/28] refactor: remove gattcallback Signed-off-by: Reyva Babtista (cherry picked from commit 521451f185cb16356ca92ad959d2d2362efd7836) --- .../app/listeners/OmniRingGattListener.kt | 87 ------------------- .../beiwe/app/listeners/OmniringListener.kt | 8 +- 2 files changed, 7 insertions(+), 88 deletions(-) delete mode 100644 app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt deleted file mode 100644 index 17df2168..00000000 --- a/app/src/main/java/org/beiwe/app/listeners/OmniRingGattListener.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.beiwe.app.listeners - -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCallback -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import android.bluetooth.BluetoothProfile -import android.util.Log -import org.beiwe.app.storage.TextFileManager -import java.nio.ByteBuffer -import java.nio.ByteOrder - -object OmniRingGattListener : BluetoothGattCallback() { - private const val TAG = "OmniRingGattListener" - var lineCount = 0 - - - override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - if (newState == BluetoothProfile.STATE_CONNECTED) { - Log.d(TAG, "onConnectionStateChange: connected") - gatt?.discoverServices() - } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { - } - } - - - override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { - super.onServicesDiscovered(gatt, status) - if (status == BluetoothGatt.GATT_SUCCESS) { - gatt?.services?.forEach { service -> - Log.d(TAG, "gatt service: ${service.uuid}") - service.characteristics?.forEach { characteristic -> - Log.d(TAG, "gatt service characteristic: ${characteristic.uuid}") - gatt?.readCharacteristic(characteristic) - } - } - } - } - - override fun onCharacteristicRead( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - value: ByteArray, - status: Int - ) { - if (status == BluetoothGatt.GATT_SUCCESS) { - // TODO - } - } - - override fun onCharacteristicChanged( - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic? - ) { - if (lineCount > 1000) { - TextFileManager.getOmniRingLog().newFile() - lineCount = 0 - } - val data = decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") + "\n" - Log.d(TAG, "onCharacteristicChanged: $data") - TextFileManager.getOmniRingLog().writeEncrypted(data) - lineCount++ - } - - override fun onDescriptorWrite( - gatt: BluetoothGatt?, - descriptor: BluetoothGattDescriptor?, - status: Int - ) { - // TODO - } - - fun unpackFByteArray(byteArray: ByteArray): Float { - val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN) - return buffer.float - } - - fun decodeByteData(byteData: ByteArray): List { - val floatArray = mutableListOf() - for (i in byteData.indices step 4) { - val tmpFloat = unpackFByteArray(byteData.copyOfRange(i, i + 4)) - floatArray.add(tmpFloat) - } - return floatArray - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index cc249fca..b79ca724 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -30,6 +30,7 @@ class OmniringListener : Service() { private var bluetoothLeScanner: BluetoothLeScanner? = null private var omniringDevice: BluetoothDevice? = null private var omniringDataCharacteristic: BluetoothGattCharacteristic? = null + private var lineCount = 0 var running = false var exists: Boolean = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) @@ -142,10 +143,16 @@ class OmniringListener : Service() { gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic? ) { + if (lineCount > 1000) { + TextFileManager.getOmniRingLog().newFile() + lineCount = 0 + } + TextFileManager.getOmniRingLog().writeEncrypted( System.currentTimeMillis().toString() + "," + decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") ) + lineCount++ } override fun onDescriptorWrite( @@ -182,7 +189,6 @@ class OmniringListener : Service() { PermissionHandler.checkBluetoothPermissions(this@OmniringListener) && device.name.startsWith("PPG_Ring") ) { - TextFileManager.getOmniRingLog().newFile() omniringDevice = device if (device.bondState != BluetoothAdapter.STATE_CONNECTED) connect(device.address) From 59f73d66259384e07d0745df2103315a578a1dc3 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 14 Oct 2024 14:04:04 -0500 Subject: [PATCH 17/28] refactor: revert https Signed-off-by: Reyva Babtista (cherry picked from commit 57412ae14e602d409ccfd231dccb82e3d3dd3b7c) --- app/src/main/java/org/beiwe/app/storage/PersistentData.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt index 1133c12d..ebbf9628 100644 --- a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt +++ b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt @@ -492,7 +492,7 @@ object PersistentData { } else if (serverUrl.startsWith("http://")) { "https://" + serverUrl.substring(7, serverUrl.length) } else { - "http://$serverUrl" + "https://$serverUrl" } } From 0b154b3d0a1af71868331e11874cc9974c10ee74 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 14 Oct 2024 14:09:46 -0500 Subject: [PATCH 18/28] refactor: fix omniring logic Signed-off-by: Reyva Babtista (cherry picked from commit fbf3389eb77ad32783c99575c8c557a596907dd5) --- .../main/java/org/beiwe/app/MainService.kt | 2039 +++++++++-------- .../beiwe/app/listeners/OmniringListener.kt | 33 +- 2 files changed, 1057 insertions(+), 1015 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index efe4a390..23d26803 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -1,1006 +1,1035 @@ -package org.beiwe.app - -import android.app.* -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.graphics.Color -import android.net.ConnectivityManager -import android.net.Uri -import android.os.* -import android.util.Log -import com.google.android.gms.tasks.OnCompleteListener -import com.google.android.gms.tasks.Task -import com.google.firebase.iid.FirebaseInstanceId -import com.google.firebase.iid.InstanceIdResult -import io.sentry.Sentry -import io.sentry.android.AndroidSentryClientFactory -import io.sentry.dsn.InvalidDsnException -import org.beiwe.app.PermissionHandler.checkBluetoothPermissions -import org.beiwe.app.PermissionHandler.confirmBluetooth -import org.beiwe.app.PermissionHandler.confirmCallLogging -import org.beiwe.app.PermissionHandler.confirmTexts -import org.beiwe.app.listeners.* -import org.beiwe.app.listeners.AccelerometerListener -import org.beiwe.app.listeners.AmbientAudioListener -import org.beiwe.app.listeners.BluetoothListener -import org.beiwe.app.listeners.CallLogger -import org.beiwe.app.listeners.GPSListener -import org.beiwe.app.listeners.GyroscopeListener -import org.beiwe.app.listeners.MMSSentLogger -import org.beiwe.app.listeners.OmniringListener -import org.beiwe.app.listeners.PowerStateListener -import org.beiwe.app.listeners.SmsSentLogger -import org.beiwe.app.listeners.WifiListener -import org.beiwe.app.networking.PostRequest -import org.beiwe.app.networking.SurveyDownloader -import org.beiwe.app.storage.* -import org.beiwe.app.survey.SurveyScheduler -import org.beiwe.app.ui.user.LoginActivity -import org.beiwe.app.ui.utils.SurveyNotifications.displaySurveyNotification -import org.beiwe.app.ui.utils.SurveyNotifications.isNotificationActive -import java.util.* - -// Notification Channel constants -const val NOTIFICATION_CHANNEL_ID = "_service_channel" -const val NOTIFICATION_CHANNEL_NAME = "Beiwe Data Collection" // user facing name, seen if they hold press the notification - -// Timer constants -const val FCM_TIMER = 1000L * 60 * 30 // 30 minutes between sending fcm checkins -const val DEVICE_SETTINGS_UPDATE_PERIODICITY = 1000L * 60 * 30 // 30 between checking for updated device settings updates -const val HEARTBEAT_TIMER = 1000L * 60 * 5 // 5 minutes between sending heartbeats - -// set our repeating timers to 30 seconds (threadhandler is offset by half the period) -const val FOREGROUND_SERVICE_RESTART_PERIODICITY = 1000L * 30 -const val THREADHANDLER_PERIODICITY = 1000L * 30 - -// 6 hours for a foreground service notification, this is JUST the notification, not the service itself. -const val FOREGROUND_SERVICE_NOTIFICATION_TIMER = 1000L * 60 * 60 * 6 - -const val BLLUETOOTH_MESSAGE_1 = "bluetooth Failure, device should not have gotten to this line of code" -const val BLLUETOOTH_MESSAGE_2 = "Device does not support bluetooth LE, bluetooth features disabled." -const val FCM_ERROR_MESSAGE = "Unable to get FCM token, will not be able to receive push notifications." - -// We were getting some weird null intent exceptions with invalid stack traces pointing to line 2 of -// this file. Line 2 is either an import, a package declaration, an error suppression annotation, or -// blank. All overridden functions in this file that have non-primitive variables (usually intents) -// must be declared as optional and handle that null case (usually we ignore it). - -class MainService : Service() { - // the various listeners for sensor data - var gpsListener: GPSListener? = null - var powerStateListener: PowerStateListener? = null - var accelerometerListener: AccelerometerListener? = null - var gyroscopeListener: GyroscopeListener? = null - var bluetoothListener: BluetoothListener? = null - var omniringListener: OmniringListener? = null - - // these assets don't require android assets, they can go in the common init. - val background_handlerThread = HandlerThread("background_handler_thread") - var background_handler: Handler - var background_looper: Looper - var hasInitializedOnce = false - - init { - background_handlerThread.start() - background_looper = background_handlerThread.looper - background_handler = Handler(background_looper) - } - - /*############################################################################################## - ############################## App Core Setup ########################### - ##############################################################################################*/ - - /** onCreate is essentially the constructor for the service, initialize variables here. */ - override fun onCreate() { - localHandle = this // yes yes, gross, I know. must instantiate before registerTimers() - - try { - val sentryDsn = BuildConfig.SENTRY_DSN - Sentry.init(sentryDsn, AndroidSentryClientFactory(applicationContext)) - } catch (ie: InvalidDsnException) { - Sentry.init(AndroidSentryClientFactory(applicationContext)) - } - - // report errors from the service to sentry only when this is not the development build - if (!BuildConfig.APP_IS_DEV) - Thread.setDefaultUncaughtExceptionHandler(CrashHandler(applicationContext)) - - // Accessing a survey requires thhe user opening the app or an activet survey notification, - // which means the background service is always running before that point, even in the - // corner case of when the background starts an system-on. - - PersistentData.initialize(applicationContext) - PersistentData.setNotTakingSurvey() - - // Initialize everything that is necessary for the app! - initializeFireBaseIDToken() - TextFileManager.initialize(applicationContext) - PostRequest.initialize(applicationContext) - registerTimers(applicationContext) - createNotificationChannel() - doSetup() - - // dispatch the ThreadHandler based run_all_app_logic call with a 1/2 duration offset. - background_handler.postDelayed(periodic_run_app_logic, THREADHANDLER_PERIODICITY / 2) - val start_time = Date(System.currentTimeMillis()).toLocaleString() - PersistentData.appOnServiceStart = start_time - if (!this.hasInitializedOnce) { - PersistentData.appOnServiceStartFirstRun = start_time - this.hasInitializedOnce = true - } - } - - // namespace hack, see comment - fun get_periodic_run_app_logic(): () -> Unit = periodic_run_app_logic - val periodic_run_app_logic: () -> Unit = { - // printd("run_all_app_logic - ThreadHandler") - run_all_app_logic() - // in the scope of this closure "periodic_run_app_logic" doesn't exist, we need to access it, not referency it. - background_handler.postDelayed(get_periodic_run_app_logic(), THREADHANDLER_PERIODICITY) - } - - fun doSetup() { - // Accelerometer, gyro, power state, and wifi don't need permissions or they are checked in - // the broadcastreceiver logic - startPowerStateListener() - gpsListener = GPSListener(applicationContext) - WifiListener.initialize(applicationContext) - - if (PersistentData.getAccelerometerEnabled()) - accelerometerListener = AccelerometerListener(applicationContext) - if (PersistentData.getGyroscopeEnabled()) - gyroscopeListener = GyroscopeListener(applicationContext) - - // Bluetooth, wifi, gps, calls, and texts need permissions - if (confirmBluetooth(applicationContext)) - initializeBluetoothAndOmniring() - - if (confirmTexts(applicationContext)) { - startSmsSentLogger() - startMmsSentLogger() - } else if (PersistentData.getTextsEnabled()) { - sendBroadcast(Timer.checkForSMSEnabledIntent) - } - if (confirmCallLogging(applicationContext)) - startCallLogger() - else if (PersistentData.getCallLoggingEnabled()) - sendBroadcast(Timer.checkForCallsEnabledIntent) - - // Only do the following if the device is registered - if (PersistentData.getIsRegistered()) { - DeviceInfo.initialize(applicationContext) // if at registration this has already been initialized. (we don't care.) - startTimers() - } - } - - private fun createNotificationChannel() { - // setup the notification channel so the service can run in the foreground - val chan = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT) - chan.lightColor = Color.BLUE - chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - chan.setSound(null, null) - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(chan) - } - - /*############################################################################# - ######################### Starters ####################### - #############################################################################*/ - - /** Initializes the Bluetooth listener - * Bluetooth has several checks to make sure that it actually exists on the device with the - * capabilities we need. Checking for Bluetooth LE is necessary because it is an optional - * extension to Bluetooth 4.0. */ - fun initializeBluetoothAndOmniring() { - // Note: the Bluetooth listener is a BroadcastReceiver, which means it must have a 0-argument - // constructor in order for android to instantiate it on broadcast receipts. The following - // check must be made, but it requires a Context that we cannot pass into the - // BluetoothListener, so we do the check in the BackgroundService. - if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) - && PersistentData.getBluetoothEnabled()) { - bluetoothListener = BluetoothListener() - omniringListener = OmniringListener() - if (bluetoothListener!!.isBluetoothEnabled) { - val intent_filter = IntentFilter("android.bluetooth.adapter.action.STATE_CHANGED") - registerReceiver(bluetoothListener, intent_filter) - } else { - // TODO: Track down why this error occurs, cleanup. The above check should be for - // the (new) doesBluetoothCapabilityExist function instead of isBluetoothEnabled - Log.e("Main Service", BLLUETOOTH_MESSAGE_1) - TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_1) - } - } else { - if (PersistentData.getBluetoothEnabled()) { - TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_2) - Log.w("MainS bluetooth init", BLLUETOOTH_MESSAGE_2) - } - bluetoothListener = null - } - } - - /** Initializes the sms logger. */ - fun startSmsSentLogger() { - val smsSentLogger = SmsSentLogger(Handler(), applicationContext) - this.contentResolver.registerContentObserver( - Uri.parse("content://sms/"), true, smsSentLogger) - } - - fun startMmsSentLogger() { - val mmsMonitor = MMSSentLogger(Handler(), applicationContext) - this.contentResolver.registerContentObserver( - Uri.parse("content://mms/"), true, mmsMonitor) - } - - /** Initializes the call logger. */ - private fun startCallLogger() { - val callLogger = CallLogger(Handler(), applicationContext) - this.contentResolver.registerContentObserver( - Uri.parse("content://call_log/calls/"), true, callLogger) - } - - /** Initializes the PowerStateListener. - * The PowerStateListener requires the ACTION_SCREEN_OFF and ACTION_SCREEN_ON intents be - * registered programatically. They do not work if registered in the app's manifest. Same for - * the ACTION_POWER_SAVE_MODE_CHANGED and ACTION_DEVICE_IDLE_MODE_CHANGED filters, though they - * are for monitoring deeper power state changes in 5.0 and 6.0, respectively. */ - private fun startPowerStateListener() { - if (powerStateListener == null) { - val filter = IntentFilter() - filter.addAction(Intent.ACTION_SCREEN_ON) - filter.addAction(Intent.ACTION_SCREEN_OFF) - if (Build.VERSION.SDK_INT >= 21) - filter.addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) - if (Build.VERSION.SDK_INT >= 23) - filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) - powerStateListener = PowerStateListener() - registerReceiver(powerStateListener, filter) - PowerStateListener.start(applicationContext) - } - } - - /** Gets, sets, and pushes the FCM token to the backend. */ - fun initializeFireBaseIDToken() { - // Set up the oncomplete listener for the FCM getter code, which in turn sets up a thread - // that will wait until the participant is registered to actually push it off to the server. - val fcm_closure = OnCompleteListener { task: Task -> - // If the task failed, log the error and return, we will resend in the firebase - // token-changed code, or the FCM_TIMER periodic event. - if (!task.isSuccessful) { - Log.e("FCM", FCM_ERROR_MESSAGE, task.exception) - TextFileManager.writeDebugLogStatement("$FCM_ERROR_MESSAGE(1)") - return@OnCompleteListener - } - - // Get new Instance ID token - literally can't access task.result in blocker_closure ...?! - val taskResult = task.result - if (taskResult == null) { - TextFileManager.writeDebugLogStatement("$FCM_ERROR_MESSAGE(2)") - return@OnCompleteListener - } - - // We need to wait until the participant is registered to send the fcm token. - // (This is a Runnable because we need to return early in an error case with @Runnable) - val blocker_closure = Runnable { - while (!PersistentData.getIsRegistered()) { - try { - Thread.sleep(1000) - } catch (ignored: InterruptedException) { - TextFileManager.writeDebugLogStatement("$FCM_ERROR_MESSAGE(3)") - return@Runnable - } - } - PersistentData.setFCMInstanceID(taskResult.token) - PostRequest.sendFCMInstanceID(taskResult.token) - } - - // kick off the blocker thread - Thread(blocker_closure, "fcm_blocker_thread").start() - } - - // setup oncomplete listener - FirebaseInstanceId.getInstance().instanceId.addOnCompleteListener(fcm_closure) - } - - /*############################################################################# - #################### Timer Logic ####################### - #############################################################################*/ - - fun startTimers() { - Log.i("BackgroundService", "running startTimer logic.") - // printd("run_all_app_logic - startTimers") - run_all_app_logic() - - // if Bluetooth recording is enabled and there is no scheduled next-bluetooth-enable event, - // set up the next Bluetooth-on alarm. (Bluetooth needs to run at absolute points in time, - // it should not be started if a scheduled event is missed.) - if (PersistentData.getBluetoothEnabled()) { - if (confirmBluetooth(applicationContext) && !timer!!.alarmIsSet(Timer.bluetoothOnIntent)) - timer!!.setupExactSingleAbsoluteTimeAlarm( - PersistentData.getBluetoothTotalDuration(), - PersistentData.getBluetoothGlobalOffset(), - Timer.bluetoothOnIntent - ) - } - - // this is a repeating alarm that ensures the service is running, it starts the service if it isn't. - // Periodicity is FOREGROUND_SERVICE_RESTART_PERIODICITY. - // This is a special intent, it has a construction that targets the MainService's onStartCommand method. - val alarmService = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager - val restartServiceIntent = Intent(applicationContext, MainService::class.java) - restartServiceIntent.setPackage(packageName) - val flags = pending_intent_flag_fix(PendingIntent.FLAG_UPDATE_CURRENT) - // no clue what this requestcode means, it is 0 on normal pending intents - val repeatingRestartServicePendingIntent = PendingIntent.getService( - applicationContext, 1, restartServiceIntent, flags - ) - alarmService.setRepeating( - AlarmManager.RTC_WAKEUP, - System.currentTimeMillis() + FOREGROUND_SERVICE_RESTART_PERIODICITY, - FOREGROUND_SERVICE_RESTART_PERIODICITY, - repeatingRestartServicePendingIntent - ) - } - - /**The timerReceiver is an Android BroadcastReceiver that listens for our timer events to trigger, - * and then runs the appropriate code for that trigger. - * Note: every condition has a return statement at the end; this is because the trigger survey - * notification action requires a fairly expensive dive into PersistantData JSON unpacking. */ - private val timerReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(applicationContext: Context, intent: Intent?) { - Log.e("BackgroundService", "Received broadcast: $intent") - TextFileManager.writeDebugLogStatement("Received Broadcast: " + intent.toString()) - - if (intent == null) - return - - val broadcastAction = intent.action - // printd("run_all_app_logic - timerReceiver") - run_all_app_logic() - - /* Bluetooth timers are unlike GPS and Accelerometer because it uses an - * absolute-point-in-time as a trigger, and therefore we don't need to store - * most-recent-timer state. The Bluetooth-on action sets the corresponding Bluetooth-off - * timer, the Bluetooth-off action sets the next Bluetooth-on timer.*/ - if (broadcastAction == applicationContext.getString(R.string.turn_bluetooth_on)) { - printe("Bluetooth on timer triggered") - if (!PersistentData.getBluetoothEnabled()) // return, don't set another alarm - return - - if (checkBluetoothPermissions(applicationContext)) { - if (bluetoothListener != null) bluetoothListener!!.enableBLEScan() - } else { - TextFileManager.writeDebugLogStatement("user has not provided permission for Bluetooth.") - } - timer!!.setupExactSingleAlarm(PersistentData.getBluetoothOnDuration(), Timer.bluetoothOffIntent) - return - } - - if (broadcastAction == applicationContext.getString(R.string.turn_bluetooth_off)) { - printe("Bluetooth off timer triggered") - if (checkBluetoothPermissions(applicationContext) && bluetoothListener != null) { - bluetoothListener!!.disableBLEScan() - } - timer!!.setupExactSingleAbsoluteTimeAlarm( - PersistentData.getBluetoothTotalDuration(), - PersistentData.getBluetoothGlobalOffset(), - Timer.bluetoothOnIntent - ) - return - } - - // I don't know if we pull this one out - // Signs out the user. (does not set up a timer, that is handled in activity and sign-in logic) - if (broadcastAction == applicationContext.getString(R.string.signout_intent)) { - // FIXME: does this need to run on the main thread in do_signout_check? - PersistentData.logout() - val loginPage = Intent(applicationContext, LoginActivity::class.java) // yup that is still java - loginPage.flags = Intent.FLAG_ACTIVITY_NEW_TASK - applicationContext.startActivity(loginPage) - return - } - - // leave the SMS/MMS/calls logic as-is, it is like this to ensure they are never - // enabled until the particiant presses the accept button. - if (broadcastAction == applicationContext.getString(R.string.check_for_sms_enabled)) { - if (confirmTexts(applicationContext)) { - startSmsSentLogger() - startMmsSentLogger() - } else if (PersistentData.getTextsEnabled()) - timer!!.setupExactSingleAlarm(30000L, Timer.checkForSMSEnabledIntent) - } - // logic for the call (metadata) logger - if (broadcastAction == applicationContext.getString(R.string.check_for_call_log_enabled)) { - if (confirmCallLogging(applicationContext)) { - startCallLogger() - } else if (PersistentData.getCallLoggingEnabled()) - timer!!.setupExactSingleAlarm(30000L, Timer.checkForCallsEnabledIntent) - } - - - // This code has been removed, the app now explicitly checks app state, and the call - // to send this particular broadcast is no longer used. We will retain this for now - // (June 2024) in case we had a real good reason for retaining this pattern now that - // survey state checking. - // checks if the action is the id of a survey (expensive), if so pop up the notification - // for that survey, schedule the next alarm. - // if (PersistentData.getSurveyIds().contains(broadcastAction)) { - // // Log.i("MAIN SERVICE", "new notification: " + broadcastAction); - // displaySurveyNotification(applicationContext, broadcastAction!!) - // SurveyScheduler.scheduleSurvey(broadcastAction) - // return - // } - - // these are special actions that will only run if the app device is in debug mode. - if (broadcastAction == "crashBeiwe" && BuildConfig.APP_IS_BETA) { - throw NullPointerException("beeeeeoooop.") - } - if (broadcastAction == "enterANR" && BuildConfig.APP_IS_BETA) { - try { - Thread.sleep(100000) - } catch (ie: InterruptedException) { - ie.printStackTrace() - } - } - } - } - - /*############################################################################## - ########################## Application State Logic ############################# - ##############################################################################*/ - - // TODO: if we make this use rtc time that will solve time-reset issues. Could also run a sanity check. - // TODO: test default (should be zero? out of PersistentData) cases. - // TODO: is there an advantage to sticking the callables onto a queue that is then consumed? leaning no. - - /* Abstract function, checks the time, runs the action, sets the next time. */ - fun do_an_event_session_check( - now: Long, - identifier_string: String, - periodicity_in_milliseconds: Long, - do_action: () -> Unit, - ) { - // val t1 = System.currentTimeMillis() - val most_recent_event_time = PersistentData.getMostRecentAlarmTime(identifier_string) - if (now - most_recent_event_time > periodicity_in_milliseconds) { - // printe("'$identifier_string' - time to trigger") - do_action() - PersistentData.setMostRecentAlarmTime(identifier_string, System.currentTimeMillis()) - // TODO: this purely mimicks the old behavior that was of printing the broadcast, refine it. - TextFileManager.writeDebugLogStatement( - "Received Broadcast: " + Timer.intent_map[identifier_string]!!.toString()) - - // printv("'$identifier_string - trigger - ${System.currentTimeMillis() - t1}") - } else { - // printi("'$identifier_string' - not yet time to trigger") - // printv("'$identifier_string - not trigger - ${System.currentTimeMillis() - t1}") - } - } - - /* Abstracted timing logic for sessions that have a duration to their recording session. All - * events that have a timed on-off pattern run through this logic. We check the event's current - * state, recorded (PersistentData) status, compare that to the current timer values for what - * the state SHOULD be, and also set off timers to in an attempt to get a well run_all_app_logic - * check. (we pad with an extra second to ensure that check hits an inflection point where - * action is required.) */ - fun do_an_on_off_session_check( - now: Long, - is_running: Boolean, - should_turn_off_at: Long, - should_turn_on_again_at: Long, - identifier_string: String, - intent_off_string: String, - on_action: () -> Unit, - off_action: () -> Unit, - ) { - // val t1 = System.currentTimeMillis() - if (is_running && now <= should_turn_off_at) { - // printw("'$identifier_string' is running, not time to turn of") - // printv("'$identifier_string - is running - ${System.currentTimeMillis() - t1}") - return - } - - // running, should be off, off is in the past - if (is_running && should_turn_off_at < now) { - // printi("'$identifier_string' time to turn off") - off_action() - val should_turn_on_again_at_safe = should_turn_on_again_at + 1000 // add a second to ensure we pass the timer - print("setting ON TIMER for $identifier_string to $should_turn_on_again_at_safe") - timer!!.setupSingleAlarmAt(should_turn_on_again_at_safe, Timer.intent_map[identifier_string]!!) - // printv("'$identifier_string - turn off - ${System.currentTimeMillis() - t1}") - } - - // not_running, should turn on is still in the future, do nothing - if (!is_running && should_turn_on_again_at >= now) { - // printw("'$identifier_string' correctly off") - // printv("'$identifier_string - correctly off - ${System.currentTimeMillis() - t1}") - return - } - - // not running, should be on, (on is in the past) - if (!is_running && should_turn_on_again_at < now) { - // always get the current time, the now value could be stale - unlikely but possible we - // care that we get data, not that data be rigidly accurate to a clock. - PersistentData.setMostRecentAlarmTime(identifier_string, System.currentTimeMillis()) - // printe("'$identifier_string' turn it on!") - on_action() - val should_turn_off_at_safe = should_turn_off_at + 1000 // add a second to ensure we pass the timer - print("setting OFF TIMER for $identifier_string to $should_turn_off_at_safe") - timer!!.setupSingleAlarmAt(should_turn_off_at_safe, Timer.intent_map[intent_off_string]!!) - // printv("'$identifier_string - on action - ${System.currentTimeMillis() - t1}") - } - } - - /* If there is something with app state logic that should be idempotently checked, stick it - * here. Returns the value used for the current time. @Synchronized because this is the core - * logic loop. Provided potentially-expensive operations, like upload logic, run on anynchronously - * and with reasonably widely separated real-time values, */ - @Synchronized - fun run_all_app_logic(): Long { - PersistentData.appOnRunAllLogic = Date(System.currentTimeMillis()).toLocaleString() - - val now = System.currentTimeMillis() - // ALL of these actions wait until the participant is registered - if (!PersistentData.getIsRegistered()) - return now - - // These are currently syncronous (block) unless they say otherwise, profiling was done - // on a Pixel 6. No-action always measures 0-1ms. - do_new_files_check(now) // always 10s of ms (30-70ms) - do_heartbeat_check(now) // always 10s of ms (30-70ms) - accelerometer_logic(now) - omniring_logic(now) - gyro_logic(now) // on action ~20-50ms, off action 10-20ms - gps_logic(now) // on acction <10-20ms, off action ~2ms (yes two) - ambient_audio_logic(now) // asynchronous when stopping because it has to encrypt - do_fcm_upload_logic_check(now) // asynchronous, runs network request on a thread. - do_wifi_logic_check(now) // on action <10-40ms - do_upload_logic_check(now) // asynchronous, runs network request on a thread, single digit ms. - do_new_surveys_check(now) // asynchronous, runs network request on a thread, single digit ms. - do_new_device_settings_check(now) // asynchronous, runs network request on a thread, single digit ms. - do_survey_notifications_check(now) // 1 survey notification <10-30ms. - // highest total time was 159ms, but insufficient data points to be confident. - // printd("run_all_app_logic total time - ${System.currentTimeMillis() - now}") - return now - } - - fun omniring_logic(now: Long) { - if (!PersistentData.getOmniRingEnabled() || omniringListener?.exists == false) - return - - val on_string = getString(R.string.turn_omniring_on) - val off_string = getString(R.string.turn_omniring_off) - val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) - val should_turn_off_at = most_recent_on + PersistentData.getOmniringOnDuration() - val should_turn_on_again_at = - should_turn_off_at + PersistentData.getAccelerometerOffDuration() - do_an_on_off_session_check( - now, - omniringListener!!.running, - should_turn_off_at, - should_turn_on_again_at, - on_string, - off_string, - omniringListener!!.omniring_on_action, - omniringListener!!.omniring_off_action - ) - } - - fun accelerometer_logic(now: Long) { - // accelerometer may not exist, or be disabled for the study, but it does not require permissions. - if (!PersistentData.getAccelerometerEnabled() || !accelerometerListener!!.exists) - return - - // assemble all the variables we need for on-off with duration - val on_string = getString(R.string.turn_accelerometer_on) - val off_string = getString(R.string.turn_accelerometer_off) - val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) - val should_turn_off_at = most_recent_on + PersistentData.getAccelerometerOnDuration() - val should_turn_on_again_at = should_turn_off_at + PersistentData.getAccelerometerOffDuration() - do_an_on_off_session_check( - now, - accelerometerListener!!.running, - should_turn_off_at, - should_turn_on_again_at, - on_string, - off_string, - accelerometerListener!!.accelerometer_on_action, - accelerometerListener!!.accelerometer_off_action - ) - } - - fun gyro_logic(now: Long) { - // gyro may not exist, or be disabled for the study, but it does not require permissions. - if (!PersistentData.getGyroscopeEnabled() || !gyroscopeListener!!.exists) - return - - // assemble all the variables we need for on-off with duration - val on_string = getString(R.string.turn_gyroscope_on) - val off_string = getString(R.string.turn_gyroscope_off) - val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) - val should_turn_off_at = most_recent_on + PersistentData.getGyroscopeOnDuration() - val should_turn_on_again_at = should_turn_off_at + PersistentData.getGyroscopeOffDuration() - do_an_on_off_session_check( - now, - gyroscopeListener!!.running, - should_turn_off_at, - should_turn_on_again_at, - on_string, - off_string, - gyroscopeListener!!.gyro_on_action, - gyroscopeListener!!.gyro_off_action - ) - } - - fun gps_logic(now: Long) { - // GPS (location service) always _exists to a degree_, checked inside the gpsListener, - // but may not be enabled on a study. GPS requires permissions. - if (!PermissionHandler.confirmGps(applicationContext)) - return - - // assemble all the variables we need for on-off with duration - val on_string = getString(R.string.turn_gps_on) - val off_string = getString(R.string.turn_gps_off) - val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) - val should_turn_off_at = most_recent_on + PersistentData.getGpsOnDuration() - val should_turn_on_again_at = should_turn_off_at + PersistentData.getGpsOffDuration() - do_an_on_off_session_check( - now, - gpsListener!!.running, - should_turn_off_at, - should_turn_on_again_at, - on_string, - off_string, - gpsListener!!.gps_on_action, - gpsListener!!.gps_off_action - ) - } - - fun ambient_audio_logic(now: Long) { - // check permissions and enablement - if (!PermissionHandler.confirmAmbientAudioCollection(applicationContext)) - return - - val on_string = getString(R.string.turn_ambient_audio_on) - val off_string = getString(R.string.turn_ambient_audio_off) - val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) - val should_turn_off_at = most_recent_on + PersistentData.getAmbientAudioOnDuration() - val should_turn_on_again_at = should_turn_off_at + PersistentData.getAmbientAudioOffDuration() - - // ambiant audio needs the app context at runtime (we write very consistent code) - val ambient_audio_on = { - AmbientAudioListener.startRecording(applicationContext) - } - do_an_on_off_session_check( - now, - AmbientAudioListener.isCurrentlyRunning, - should_turn_off_at, - should_turn_on_again_at, - on_string, - off_string, - ambient_audio_on, - AmbientAudioListener.ambient_audio_off_action - ) - } - - fun do_wifi_logic_check(now: Long) { - // wifi has permissions and may be disabled on the study - if (!PermissionHandler.confirmWifi(applicationContext)) - return - - val event_string = getString(R.string.run_wifi_log) - val event_frequency = PersistentData.getWifiLogFrequency() - val wifi_do_action = { // wifi will need some attention to convert to kotlin... - WifiListener.scanWifi() - } - do_an_event_session_check(now, event_string, event_frequency, wifi_do_action) - } - - fun do_upload_logic_check(now: Long) { - val upload_string = applicationContext.getString(R.string.upload_data_files_intent) - val periodicity = PersistentData.getUploadDataFilesFrequency() - val do_uploads_action = { - PostRequest.uploadAllFiles() - } - do_an_event_session_check(now, upload_string, periodicity, do_uploads_action) - } - - fun do_fcm_upload_logic_check(now: Long) { - // we can just literally hardcode this one, sendFcmToken is this plus a timer - val event_string = applicationContext.getString(R.string.fcm_upload) - val send_fcm_action = { - val fcm_token = PersistentData.getFCMInstanceID() - if (fcm_token != null) - PostRequest.sendFCMInstanceID(fcm_token) - } - do_an_event_session_check(now, event_string, FCM_TIMER, send_fcm_action) - } - - fun do_heartbeat_check(now: Long) { - val event_string = getString(R.string.heartbeat_intent) - val periodicity = HEARTBEAT_TIMER - val heartbeat_action = { - PostRequest.sendHeartbeat() - } - do_an_event_session_check(now, event_string, periodicity, heartbeat_action) - } - - fun do_new_files_check(now: Long) { - val event_string = getString(R.string.create_new_data_files_intent) - val periodicity = PersistentData.getCreateNewDataFilesFrequency() - val new_files_action = { - TextFileManager.makeNewFilesForEverything() - } - do_an_event_session_check(now, event_string, periodicity, new_files_action) - } - - fun do_new_surveys_check(now: Long) { - val event_string = getString(R.string.check_for_new_surveys_intent) - val periodicity = PersistentData.getCheckForNewSurveysFrequency() - val dowwnload_surveys_action = { - SurveyDownloader.downloadSurveys(applicationContext, null) - } - do_an_event_session_check(now, event_string, periodicity, dowwnload_surveys_action) - } - - fun do_new_device_settings_check(now: Long) { - val event_string = getString(R.string.check_for_new_device_settings_intent) - val download_device_settings_action = { - SetDeviceSettings.dispatchUpdateDeviceSettings() - } - do_an_event_session_check( - now, - event_string, - DEVICE_SETTINGS_UPDATE_PERIODICITY, - download_device_settings_action - ) - } - - /** Checks for the current expected state for survey notifications, and the app state for - * scheduled alarms. */ - fun do_survey_notifications_check(now: Long) { - // val t1 = System.currentTimeMillis() - // var counter = 0 - for (surveyId in PersistentData.getSurveyIds()) { - var app_state_says_on = PersistentData.getSurveyNotificationState(surveyId) - var alarm_in_past = PersistentData.getMostRecentSurveyAlarmTime(surveyId) < now - - // the behavior is that it ... replaces the notification. - if ((app_state_says_on || alarm_in_past) && !isNotificationActive(applicationContext, surveyId)) { - // this calls PersistentData.setSurveyNotificationState - displaySurveyNotification(applicationContext, surveyId) - // counter++ - } - - // TODO: fix this naming mismatch. - // Never call this: - // timer!!.cancelAlarm(Intent(surveyId)) // BAD! - // setMostRecentSurveyAlarmTime is called in Timer.setupSurveyAlarm (when the alarm - // is set in the scheduler logic), e.g. the application state is updated to say that - // the _next_ survey alarm time is at X o'clock - there is a naming mismatch. - - // if there is no alarm set for this survey, set it. - if (!timer!!.alarmIsSet(Intent(surveyId))) - SurveyScheduler.scheduleSurvey(surveyId) - } - // printv("$counter survey notifications took ${System.currentTimeMillis() - t1} ms") - } - - /*########################################################################################## - ############## code related to onStartCommand and binding to an activity ################### - ##########################################################################################*/ - - override fun onBind(arg0: Intent?): IBinder? { - return BackgroundServiceBinder() - } - - /**A public "Binder" class for Activities to access. - * Provides a (safe) handle to the Main Service using the onStartCommand code used in every - * RunningBackgroundServiceActivity */ - inner class BackgroundServiceBinder : Binder() { - val service: MainService - get() = this@MainService - } - - /*############################################################################## - ########################## Android Service Lifecycle ########################### - ##############################################################################*/ - - /** The BackgroundService is meant to be all the time, so we return START_STICKY */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // Log.d("BackgroundService onStartCommand", "started with flag " + flags ); - TextFileManager.writeDebugLogStatement( - System.currentTimeMillis().toString() + " started with flag " + flags) - val now = System.currentTimeMillis() - val millisecondsSincePrevious = now - foregroundServiceLastStarted - - // if it has been FOREGROUND_SERVICE_TIMER or longer since the last time we started the - // foreground service notification, start it again. - if (foregroundServiceLastStarted == 0L || millisecondsSincePrevious > FOREGROUND_SERVICE_NOTIFICATION_TIMER) { - val intent_to_start_foreground_service = Intent(applicationContext, MainService::class.java) - val intent_flags = pending_intent_flag_fix(PendingIntent.FLAG_UPDATE_CURRENT) // no flags - val onStartCommandPendingIntent = PendingIntent.getService( - applicationContext, 0, intent_to_start_foreground_service, intent_flags - ) - val notification = Notification.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setContentTitle("Beiwe App") - .setContentText("Beiwe data collection running") - .setSmallIcon(R.mipmap.ic_launcher) - .setContentIntent(onStartCommandPendingIntent) - .setTicker("Beiwe data collection running in the background, no action required") - .build() - - // multiple sources recommend an ID of 1 because it works. documentation is very spotty about this - startForeground(1, notification) - foregroundServiceLastStarted = now - } - - PersistentData.serviceStartCommand = Date(System.currentTimeMillis()).toLocaleString() - - // onStartCommand is called every 30 seconds due to repeating high-priority-or-whatever - // alarms, so we will stick a core logic check here. - // printd("run_all_app_logic - onStartCommand") - run_all_app_logic() - - // We want this service to continue running until it is explicitly stopped, so return sticky. - return START_STICKY - // in testing out this restarting behavior for the service it is entirely unclear if changing - // this return will have any observable effect despite the documentation's claim that it does. - // return START_REDELIVER_INTENT; - } - - // the rest of these are ~identical - override fun onTaskRemoved(rootIntent: Intent?) { - // Log.d("BackroundService onTaskRemoved", "onTaskRemoved called with intent: " + rootIntent.toString() ); - TextFileManager.writeDebugLogStatement("onTaskRemoved called with intent: $rootIntent") - PersistentData.serviceOnTaskRemoved = Date(System.currentTimeMillis()).toLocaleString() - restartService() - } - - override fun onUnbind(intent: Intent?): Boolean { - // Log.d("BackroundService onUnbind", "onUnbind called with intent: " + intent.toString() ); - TextFileManager.writeDebugLogStatement("onUnbind called with intent: $intent") - PersistentData.serviceOnUnbind = Date(System.currentTimeMillis()).toLocaleString() - restartService() - return super.onUnbind(intent) - } - - override fun onDestroy() { // Log.w("BackgroundService", "BackgroundService was destroyed."); - // note: this does not run when the service is killed in a task manager, OR when the stopService() function is called from debugActivity. - TextFileManager.writeDebugLogStatement("BackgroundService was destroyed.") - PersistentData.serviceOnDestroy = Date(System.currentTimeMillis()).toLocaleString() - restartService() - super.onDestroy() - } - - override fun onLowMemory() { // Log.w("BackroundService onLowMemory", "Low memory conditions encountered"); - TextFileManager.writeDebugLogStatement("onLowMemory called.") - PersistentData.serviceOnLowMemory = Date(System.currentTimeMillis()).toLocaleString() - restartService() - } - - override fun onTrimMemory(level: Int) { - // Log.w("BackroundService onTrimMemory", "Trim memory conditions encountered"); - TextFileManager.writeDebugLogStatement("onTrimMemory called.") - PersistentData.serviceOnTrimMemory = Date(System.currentTimeMillis()).toLocaleString() - super.onTrimMemory(level) - } - - /** Stops the BackgroundService instance, development tool */ - fun stop() { - if (BuildConfig.APP_IS_BETA) - this.stopSelf() - } - - /** Sets a timer that starts the service if it is not running after a half second. */ - fun restartService() { - val restartServiceIntent = Intent(applicationContext, this.javaClass) - restartServiceIntent.setPackage(packageName) - val restartServicePendingIntent = PendingIntent.getService( - applicationContext, - 1, - restartServiceIntent, - pending_intent_flag_fix(PendingIntent.FLAG_ONE_SHOT) - ) - // kotlin port action turned this into a very weird setter syntax using [] access... - (applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager).set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 500, - restartServicePendingIntent - ) - } - - /** We sometimes need to restart the background service */ - fun exit_and_restart_background_service() { - TextFileManager.writeDebugLogStatement("manually restarting background service") - // if this takes more than 500ms to restart, the app will ~crash... hmm. This is fine. - restartService() - System.exit(0) - } - - // static assets - companion object { - // I guess we need access to this one in static contexts... - public var timer: Timer? = null - - // localHandle is how static scopes access the currently instantiated main service. - // It is to be used ONLY to register new surveys with the running main service, because - // that code needs to be able to update the IntentFilters associated with timerReceiver. - // This is Really Hacky and terrible style, but it is okay because the scheduling code can only ever - // begin to run with an already fully instantiated main service. - var localHandle: MainService? = null - - private var foregroundServiceLastStarted = 0L - - // FIXME: in order to make this non-static we probably need to port PostReqeuest to kotlin - /** create timers that will trigger events throughout the program, and - * register the custom Intents with the controlMessageReceiver. */ - @JvmStatic - fun registerTimers(applicationContext: Context) { - timer = Timer(localHandle!!) - val filter = IntentFilter() - filter.addAction(applicationContext.getString(R.string.turn_accelerometer_off)) - filter.addAction(applicationContext.getString(R.string.turn_accelerometer_on)) - filter.addAction(applicationContext.getString(R.string.turn_ambient_audio_off)) - filter.addAction(applicationContext.getString(R.string.turn_ambient_audio_on)) - filter.addAction(applicationContext.getString(R.string.turn_gyroscope_on)) - filter.addAction(applicationContext.getString(R.string.turn_gyroscope_off)) - filter.addAction(applicationContext.getString(R.string.turn_bluetooth_on)) - filter.addAction(applicationContext.getString(R.string.turn_bluetooth_off)) - filter.addAction(applicationContext.getString(R.string.turn_omniring_on)) - filter.addAction(applicationContext.getString(R.string.turn_gps_on)) - filter.addAction(applicationContext.getString(R.string.turn_gps_off)) - filter.addAction(applicationContext.getString(R.string.signout_intent)) - filter.addAction(applicationContext.getString(R.string.voice_recording)) - filter.addAction(applicationContext.getString(R.string.run_wifi_log)) - filter.addAction(applicationContext.getString(R.string.upload_data_files_intent)) - filter.addAction(applicationContext.getString(R.string.create_new_data_files_intent)) - filter.addAction(applicationContext.getString(R.string.check_for_new_surveys_intent)) - filter.addAction(applicationContext.getString(R.string.check_for_sms_enabled)) - filter.addAction(applicationContext.getString(R.string.check_for_call_log_enabled)) - filter.addAction(applicationContext.getString(R.string.check_if_ambient_audio_recording_is_enabled)) - filter.addAction(applicationContext.getString(R.string.fcm_upload)) - filter.addAction(applicationContext.getString(R.string.check_for_new_device_settings_intent)) - filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION) - filter.addAction("crashBeiwe") - filter.addAction("enterANR") - - for (surveyId in PersistentData.getSurveyIds()) { - filter.addAction(surveyId) - } - applicationContext.registerReceiver(localHandle!!.timerReceiver, filter) - } - - /**Refreshes the logout timer. - * This function has a THEORETICAL race condition, where the BackgroundService is not fully instantiated by a session activity, - * in this case we log an error to the debug log, print the error, and then wait for it to crash. In testing on a (much) older - * version of the app we would occasionally see the error message, but we have never (august 10 2015) actually seen the app crash - * inside this code. */ - fun startAutomaticLogoutCountdownTimer() { - if (timer == null) { - Log.e("bacgroundService", "timer is null, BackgroundService may be about to crash, the Timer was null when the BackgroundService was supposed to be fully instantiated.") - TextFileManager.writeDebugLogStatement("our not-quite-race-condition encountered, Timer was null when the BackgroundService was supposed to be fully instantiated") - } - timer!!.setupExactSingleAlarm(PersistentData.getTimeBeforeAutoLogout(), Timer.signoutIntent) - PersistentData.loginOrRefreshLogin() - } - - /** cancels the signout timer */ - fun clearAutomaticLogoutCountdownTimer() { - timer!!.cancelAlarm(Timer.signoutIntent) - } - - /** The Timer requires the BackgroundService in order to create alarms, hook into that functionality here. */ - @JvmStatic - fun setSurveyAlarm(surveyId: String?, alarmTime: Calendar?) { - timer!!.startSurveyAlarm(surveyId!!, alarmTime!!) - } - - @JvmStatic - fun cancelSurveyAlarm(surveyId: String?) { - timer!!.cancelAlarm(Intent(surveyId)) - } - } +package org.beiwe.app + +import android.app.* +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.graphics.Color +import android.net.ConnectivityManager +import android.net.Uri +import android.os.* +import android.util.Log +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.Task +import com.google.firebase.iid.FirebaseInstanceId +import com.google.firebase.iid.InstanceIdResult +import io.sentry.Sentry +import io.sentry.android.AndroidSentryClientFactory +import io.sentry.dsn.InvalidDsnException +import org.beiwe.app.PermissionHandler.checkBluetoothPermissions +import org.beiwe.app.PermissionHandler.confirmBluetooth +import org.beiwe.app.PermissionHandler.confirmCallLogging +import org.beiwe.app.PermissionHandler.confirmTexts +import org.beiwe.app.listeners.* +import org.beiwe.app.listeners.AccelerometerListener +import org.beiwe.app.listeners.AmbientAudioListener +import org.beiwe.app.listeners.BluetoothListener +import org.beiwe.app.listeners.CallLogger +import org.beiwe.app.listeners.GPSListener +import org.beiwe.app.listeners.GyroscopeListener +import org.beiwe.app.listeners.MMSSentLogger +import org.beiwe.app.listeners.OmniringListener +import org.beiwe.app.listeners.PowerStateListener +import org.beiwe.app.listeners.SmsSentLogger +import org.beiwe.app.listeners.WifiListener +import org.beiwe.app.networking.PostRequest +import org.beiwe.app.networking.SurveyDownloader +import org.beiwe.app.storage.* +import org.beiwe.app.survey.SurveyScheduler +import org.beiwe.app.ui.user.LoginActivity +import org.beiwe.app.ui.utils.SurveyNotifications.displaySurveyNotification +import org.beiwe.app.ui.utils.SurveyNotifications.isNotificationActive +import java.util.* + +// Notification Channel constants +const val NOTIFICATION_CHANNEL_ID = "_service_channel" +const val NOTIFICATION_CHANNEL_NAME = "Beiwe Data Collection" // user facing name, seen if they hold press the notification + +// Timer constants +const val FCM_TIMER = 1000L * 60 * 30 // 30 minutes between sending fcm checkins +const val DEVICE_SETTINGS_UPDATE_PERIODICITY = 1000L * 60 * 30 // 30 between checking for updated device settings updates +const val HEARTBEAT_TIMER = 1000L * 60 * 5 // 5 minutes between sending heartbeats + +// set our repeating timers to 30 seconds (threadhandler is offset by half the period) +const val FOREGROUND_SERVICE_RESTART_PERIODICITY = 1000L * 30 +const val THREADHANDLER_PERIODICITY = 1000L * 30 + +// 6 hours for a foreground service notification, this is JUST the notification, not the service itself. +const val FOREGROUND_SERVICE_NOTIFICATION_TIMER = 1000L * 60 * 60 * 6 + +const val BLLUETOOTH_MESSAGE_1 = "bluetooth Failure, device should not have gotten to this line of code" +const val BLLUETOOTH_MESSAGE_2 = "Device does not support bluetooth LE, bluetooth features disabled." +const val FCM_ERROR_MESSAGE = "Unable to get FCM token, will not be able to receive push notifications." + +// We were getting some weird null intent exceptions with invalid stack traces pointing to line 2 of +// this file. Line 2 is either an import, a package declaration, an error suppression annotation, or +// blank. All overridden functions in this file that have non-primitive variables (usually intents) +// must be declared as optional and handle that null case (usually we ignore it). + +class MainService : Service() { + // the various listeners for sensor data + var gpsListener: GPSListener? = null + var powerStateListener: PowerStateListener? = null + var accelerometerListener: AccelerometerListener? = null + var gyroscopeListener: GyroscopeListener? = null + var bluetoothListener: BluetoothListener? = null + var omniringListener: OmniringListener? = null + + // these assets don't require android assets, they can go in the common init. + val background_handlerThread = HandlerThread("background_handler_thread") + var background_handler: Handler + var background_looper: Looper + var hasInitializedOnce = false + + init { + background_handlerThread.start() + background_looper = background_handlerThread.looper + background_handler = Handler(background_looper) + } + + /*############################################################################################## + ############################## App Core Setup ########################### + ##############################################################################################*/ + + /** onCreate is essentially the constructor for the service, initialize variables here. */ + override fun onCreate() { + localHandle = this // yes yes, gross, I know. must instantiate before registerTimers() + + try { + val sentryDsn = BuildConfig.SENTRY_DSN + Sentry.init(sentryDsn, AndroidSentryClientFactory(applicationContext)) + } catch (ie: InvalidDsnException) { + Sentry.init(AndroidSentryClientFactory(applicationContext)) + } + + // report errors from the service to sentry only when this is not the development build + if (!BuildConfig.APP_IS_DEV) + Thread.setDefaultUncaughtExceptionHandler(CrashHandler(applicationContext)) + + // Accessing a survey requires thhe user opening the app or an activet survey notification, + // which means the background service is always running before that point, even in the + // corner case of when the background starts an system-on. + + PersistentData.initialize(applicationContext) + PersistentData.setNotTakingSurvey() + + // Initialize everything that is necessary for the app! + initializeFireBaseIDToken() + TextFileManager.initialize(applicationContext) + PostRequest.initialize(applicationContext) + registerTimers(applicationContext) + createNotificationChannel() + doSetup() + + // dispatch the ThreadHandler based run_all_app_logic call with a 1/2 duration offset. + background_handler.postDelayed(periodic_run_app_logic, THREADHANDLER_PERIODICITY / 2) + val start_time = Date(System.currentTimeMillis()).toLocaleString() + PersistentData.appOnServiceStart = start_time + if (!this.hasInitializedOnce) { + PersistentData.appOnServiceStartFirstRun = start_time + this.hasInitializedOnce = true + } + } + + // namespace hack, see comment + fun get_periodic_run_app_logic(): () -> Unit = periodic_run_app_logic + val periodic_run_app_logic: () -> Unit = { + // printd("run_all_app_logic - ThreadHandler") + run_all_app_logic() + // in the scope of this closure "periodic_run_app_logic" doesn't exist, we need to access it, not referency it. + background_handler.postDelayed(get_periodic_run_app_logic(), THREADHANDLER_PERIODICITY) + } + + fun doSetup() { + // Accelerometer, gyro, power state, and wifi don't need permissions or they are checked in + // the broadcastreceiver logic + startPowerStateListener() + gpsListener = GPSListener(applicationContext) + WifiListener.initialize(applicationContext) + + if (PersistentData.getAccelerometerEnabled()) + accelerometerListener = AccelerometerListener(applicationContext) + if (PersistentData.getGyroscopeEnabled()) + gyroscopeListener = GyroscopeListener(applicationContext) + + // Bluetooth, wifi, gps, calls, and texts need permissions + if (confirmBluetooth(applicationContext)) + initializeBluetoothAndOmniring() + + if (confirmTexts(applicationContext)) { + startSmsSentLogger() + startMmsSentLogger() + } else if (PersistentData.getTextsEnabled()) { + sendBroadcast(Timer.checkForSMSEnabledIntent) + } + if (confirmCallLogging(applicationContext)) + startCallLogger() + else if (PersistentData.getCallLoggingEnabled()) + sendBroadcast(Timer.checkForCallsEnabledIntent) + + // Only do the following if the device is registered + if (PersistentData.getIsRegistered()) { + DeviceInfo.initialize(applicationContext) // if at registration this has already been initialized. (we don't care.) + startTimers() + } + } + + private fun createNotificationChannel() { + // setup the notification channel so the service can run in the foreground + val chan = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT) + chan.lightColor = Color.BLUE + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + chan.setSound(null, null) + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(chan) + } + + /*############################################################################# + ######################### Starters ####################### + #############################################################################*/ + private val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as OmniringListener.LocalBinder + omniringListener = binder.getService() + Log.d("omniring", "onServiceConnected: omniringListener bound") + } + + override fun onServiceDisconnected(arg0: ComponentName) { + omniringListener = null + Log.d("omniring", "onServiceConnected: omniringListener unbound") + } + } + + + /** Initializes the Bluetooth listener + * Bluetooth has several checks to make sure that it actually exists on the device with the + * capabilities we need. Checking for Bluetooth LE is necessary because it is an optional + * extension to Bluetooth 4.0. */ + fun initializeBluetoothAndOmniring() { + // Note: the Bluetooth listener is a BroadcastReceiver, which means it must have a 0-argument + // constructor in order for android to instantiate it on broadcast receipts. The following + // check must be made, but it requires a Context that we cannot pass into the + // BluetoothListener, so we do the check in the BackgroundService. + if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + if (PersistentData.getBluetoothEnabled()) { + bluetoothListener = BluetoothListener() + if (bluetoothListener!!.isBluetoothEnabled) { + val intent_filter = + IntentFilter("android.bluetooth.adapter.action.STATE_CHANGED") + registerReceiver(bluetoothListener, intent_filter) + } else { + // TODO: Track down why this error occurs, cleanup. The above check should be for + // the (new) doesBluetoothCapabilityExist function instead of isBluetoothEnabled + Log.e("Main Service", BLLUETOOTH_MESSAGE_1) + TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_1) + } + } + if (PersistentData.getOmniRingEnabled()) { + val intent = Intent(this, OmniringListener::class.java).also { intent -> + bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + Log.d("omniring", "initializeBluetoothAndOmniring: starting omniring") + startService(intent) + } + } else { + if (PersistentData.getBluetoothEnabled()) { + TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_2) + Log.w("MainS bluetooth init", BLLUETOOTH_MESSAGE_2) + } + if (PersistentData.getOmniRingEnabled()) { + TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_2) + Log.w("MainS omniring init", BLLUETOOTH_MESSAGE_2) + } + bluetoothListener = null + omniringListener = null + } + } + + /** Initializes the sms logger. */ + fun startSmsSentLogger() { + val smsSentLogger = SmsSentLogger(Handler(), applicationContext) + this.contentResolver.registerContentObserver( + Uri.parse("content://sms/"), true, smsSentLogger) + } + + fun startMmsSentLogger() { + val mmsMonitor = MMSSentLogger(Handler(), applicationContext) + this.contentResolver.registerContentObserver( + Uri.parse("content://mms/"), true, mmsMonitor) + } + + /** Initializes the call logger. */ + private fun startCallLogger() { + val callLogger = CallLogger(Handler(), applicationContext) + this.contentResolver.registerContentObserver( + Uri.parse("content://call_log/calls/"), true, callLogger) + } + + /** Initializes the PowerStateListener. + * The PowerStateListener requires the ACTION_SCREEN_OFF and ACTION_SCREEN_ON intents be + * registered programatically. They do not work if registered in the app's manifest. Same for + * the ACTION_POWER_SAVE_MODE_CHANGED and ACTION_DEVICE_IDLE_MODE_CHANGED filters, though they + * are for monitoring deeper power state changes in 5.0 and 6.0, respectively. */ + private fun startPowerStateListener() { + if (powerStateListener == null) { + val filter = IntentFilter() + filter.addAction(Intent.ACTION_SCREEN_ON) + filter.addAction(Intent.ACTION_SCREEN_OFF) + if (Build.VERSION.SDK_INT >= 21) + filter.addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) + if (Build.VERSION.SDK_INT >= 23) + filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + powerStateListener = PowerStateListener() + registerReceiver(powerStateListener, filter) + PowerStateListener.start(applicationContext) + } + } + + /** Gets, sets, and pushes the FCM token to the backend. */ + fun initializeFireBaseIDToken() { + // Set up the oncomplete listener for the FCM getter code, which in turn sets up a thread + // that will wait until the participant is registered to actually push it off to the server. + val fcm_closure = OnCompleteListener { task: Task -> + // If the task failed, log the error and return, we will resend in the firebase + // token-changed code, or the FCM_TIMER periodic event. + if (!task.isSuccessful) { + Log.e("FCM", FCM_ERROR_MESSAGE, task.exception) + TextFileManager.writeDebugLogStatement("$FCM_ERROR_MESSAGE(1)") + return@OnCompleteListener + } + + // Get new Instance ID token - literally can't access task.result in blocker_closure ...?! + val taskResult = task.result + if (taskResult == null) { + TextFileManager.writeDebugLogStatement("$FCM_ERROR_MESSAGE(2)") + return@OnCompleteListener + } + + // We need to wait until the participant is registered to send the fcm token. + // (This is a Runnable because we need to return early in an error case with @Runnable) + val blocker_closure = Runnable { + while (!PersistentData.getIsRegistered()) { + try { + Thread.sleep(1000) + } catch (ignored: InterruptedException) { + TextFileManager.writeDebugLogStatement("$FCM_ERROR_MESSAGE(3)") + return@Runnable + } + } + PersistentData.setFCMInstanceID(taskResult.token) + PostRequest.sendFCMInstanceID(taskResult.token) + } + + // kick off the blocker thread + Thread(blocker_closure, "fcm_blocker_thread").start() + } + + // setup oncomplete listener + FirebaseInstanceId.getInstance().instanceId.addOnCompleteListener(fcm_closure) + } + + /*############################################################################# + #################### Timer Logic ####################### + #############################################################################*/ + + fun startTimers() { + Log.i("BackgroundService", "running startTimer logic.") + // printd("run_all_app_logic - startTimers") + run_all_app_logic() + + // if Bluetooth recording is enabled and there is no scheduled next-bluetooth-enable event, + // set up the next Bluetooth-on alarm. (Bluetooth needs to run at absolute points in time, + // it should not be started if a scheduled event is missed.) + if (PersistentData.getBluetoothEnabled()) { + if (confirmBluetooth(applicationContext) && !timer!!.alarmIsSet(Timer.bluetoothOnIntent)) + timer!!.setupExactSingleAbsoluteTimeAlarm( + PersistentData.getBluetoothTotalDuration(), + PersistentData.getBluetoothGlobalOffset(), + Timer.bluetoothOnIntent + ) + } + + // this is a repeating alarm that ensures the service is running, it starts the service if it isn't. + // Periodicity is FOREGROUND_SERVICE_RESTART_PERIODICITY. + // This is a special intent, it has a construction that targets the MainService's onStartCommand method. + val alarmService = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager + val restartServiceIntent = Intent(applicationContext, MainService::class.java) + restartServiceIntent.setPackage(packageName) + val flags = pending_intent_flag_fix(PendingIntent.FLAG_UPDATE_CURRENT) + // no clue what this requestcode means, it is 0 on normal pending intents + val repeatingRestartServicePendingIntent = PendingIntent.getService( + applicationContext, 1, restartServiceIntent, flags + ) + alarmService.setRepeating( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + FOREGROUND_SERVICE_RESTART_PERIODICITY, + FOREGROUND_SERVICE_RESTART_PERIODICITY, + repeatingRestartServicePendingIntent + ) + } + + /**The timerReceiver is an Android BroadcastReceiver that listens for our timer events to trigger, + * and then runs the appropriate code for that trigger. + * Note: every condition has a return statement at the end; this is because the trigger survey + * notification action requires a fairly expensive dive into PersistantData JSON unpacking. */ + private val timerReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(applicationContext: Context, intent: Intent?) { + Log.e("BackgroundService", "Received broadcast: $intent") + TextFileManager.writeDebugLogStatement("Received Broadcast: " + intent.toString()) + + if (intent == null) + return + + val broadcastAction = intent.action + // printd("run_all_app_logic - timerReceiver") + run_all_app_logic() + + /* Bluetooth timers are unlike GPS and Accelerometer because it uses an + * absolute-point-in-time as a trigger, and therefore we don't need to store + * most-recent-timer state. The Bluetooth-on action sets the corresponding Bluetooth-off + * timer, the Bluetooth-off action sets the next Bluetooth-on timer.*/ + if (broadcastAction == applicationContext.getString(R.string.turn_bluetooth_on)) { + printe("Bluetooth on timer triggered") + if (!PersistentData.getBluetoothEnabled()) // return, don't set another alarm + return + + if (checkBluetoothPermissions(applicationContext)) { + if (bluetoothListener != null) bluetoothListener!!.enableBLEScan() + } else { + TextFileManager.writeDebugLogStatement("user has not provided permission for Bluetooth.") + } + timer!!.setupExactSingleAlarm(PersistentData.getBluetoothOnDuration(), Timer.bluetoothOffIntent) + return + } + + if (broadcastAction == applicationContext.getString(R.string.turn_bluetooth_off)) { + printe("Bluetooth off timer triggered") + if (checkBluetoothPermissions(applicationContext) && bluetoothListener != null) { + bluetoothListener!!.disableBLEScan() + } + timer!!.setupExactSingleAbsoluteTimeAlarm( + PersistentData.getBluetoothTotalDuration(), + PersistentData.getBluetoothGlobalOffset(), + Timer.bluetoothOnIntent + ) + return + } + + // I don't know if we pull this one out + // Signs out the user. (does not set up a timer, that is handled in activity and sign-in logic) + if (broadcastAction == applicationContext.getString(R.string.signout_intent)) { + // FIXME: does this need to run on the main thread in do_signout_check? + PersistentData.logout() + val loginPage = Intent(applicationContext, LoginActivity::class.java) // yup that is still java + loginPage.flags = Intent.FLAG_ACTIVITY_NEW_TASK + applicationContext.startActivity(loginPage) + return + } + + // leave the SMS/MMS/calls logic as-is, it is like this to ensure they are never + // enabled until the particiant presses the accept button. + if (broadcastAction == applicationContext.getString(R.string.check_for_sms_enabled)) { + if (confirmTexts(applicationContext)) { + startSmsSentLogger() + startMmsSentLogger() + } else if (PersistentData.getTextsEnabled()) + timer!!.setupExactSingleAlarm(30000L, Timer.checkForSMSEnabledIntent) + } + // logic for the call (metadata) logger + if (broadcastAction == applicationContext.getString(R.string.check_for_call_log_enabled)) { + if (confirmCallLogging(applicationContext)) { + startCallLogger() + } else if (PersistentData.getCallLoggingEnabled()) + timer!!.setupExactSingleAlarm(30000L, Timer.checkForCallsEnabledIntent) + } + + + // This code has been removed, the app now explicitly checks app state, and the call + // to send this particular broadcast is no longer used. We will retain this for now + // (June 2024) in case we had a real good reason for retaining this pattern now that + // survey state checking. + // checks if the action is the id of a survey (expensive), if so pop up the notification + // for that survey, schedule the next alarm. + // if (PersistentData.getSurveyIds().contains(broadcastAction)) { + // // Log.i("MAIN SERVICE", "new notification: " + broadcastAction); + // displaySurveyNotification(applicationContext, broadcastAction!!) + // SurveyScheduler.scheduleSurvey(broadcastAction) + // return + // } + + // these are special actions that will only run if the app device is in debug mode. + if (broadcastAction == "crashBeiwe" && BuildConfig.APP_IS_BETA) { + throw NullPointerException("beeeeeoooop.") + } + if (broadcastAction == "enterANR" && BuildConfig.APP_IS_BETA) { + try { + Thread.sleep(100000) + } catch (ie: InterruptedException) { + ie.printStackTrace() + } + } + } + } + + /*############################################################################## + ########################## Application State Logic ############################# + ##############################################################################*/ + + // TODO: if we make this use rtc time that will solve time-reset issues. Could also run a sanity check. + // TODO: test default (should be zero? out of PersistentData) cases. + // TODO: is there an advantage to sticking the callables onto a queue that is then consumed? leaning no. + + /* Abstract function, checks the time, runs the action, sets the next time. */ + fun do_an_event_session_check( + now: Long, + identifier_string: String, + periodicity_in_milliseconds: Long, + do_action: () -> Unit, + ) { + // val t1 = System.currentTimeMillis() + val most_recent_event_time = PersistentData.getMostRecentAlarmTime(identifier_string) + if (now - most_recent_event_time > periodicity_in_milliseconds) { + // printe("'$identifier_string' - time to trigger") + do_action() + PersistentData.setMostRecentAlarmTime(identifier_string, System.currentTimeMillis()) + // TODO: this purely mimicks the old behavior that was of printing the broadcast, refine it. + TextFileManager.writeDebugLogStatement( + "Received Broadcast: " + Timer.intent_map[identifier_string]!!.toString()) + + // printv("'$identifier_string - trigger - ${System.currentTimeMillis() - t1}") + } else { + // printi("'$identifier_string' - not yet time to trigger") + // printv("'$identifier_string - not trigger - ${System.currentTimeMillis() - t1}") + } + } + + /* Abstracted timing logic for sessions that have a duration to their recording session. All + * events that have a timed on-off pattern run through this logic. We check the event's current + * state, recorded (PersistentData) status, compare that to the current timer values for what + * the state SHOULD be, and also set off timers to in an attempt to get a well run_all_app_logic + * check. (we pad with an extra second to ensure that check hits an inflection point where + * action is required.) */ + fun do_an_on_off_session_check( + now: Long, + is_running: Boolean, + should_turn_off_at: Long, + should_turn_on_again_at: Long, + identifier_string: String, + intent_off_string: String, + on_action: () -> Unit, + off_action: () -> Unit, + ) { + // val t1 = System.currentTimeMillis() + if (is_running && now <= should_turn_off_at) { + // printw("'$identifier_string' is running, not time to turn of") + // printv("'$identifier_string - is running - ${System.currentTimeMillis() - t1}") + return + } + + // running, should be off, off is in the past + if (is_running && should_turn_off_at < now) { + // printi("'$identifier_string' time to turn off") + off_action() + val should_turn_on_again_at_safe = should_turn_on_again_at + 1000 // add a second to ensure we pass the timer + print("setting ON TIMER for $identifier_string to $should_turn_on_again_at_safe") + timer!!.setupSingleAlarmAt(should_turn_on_again_at_safe, Timer.intent_map[identifier_string]!!) + // printv("'$identifier_string - turn off - ${System.currentTimeMillis() - t1}") + } + + // not_running, should turn on is still in the future, do nothing + if (!is_running && should_turn_on_again_at >= now) { + // printw("'$identifier_string' correctly off") + // printv("'$identifier_string - correctly off - ${System.currentTimeMillis() - t1}") + return + } + + // not running, should be on, (on is in the past) + if (!is_running && should_turn_on_again_at < now) { + // always get the current time, the now value could be stale - unlikely but possible we + // care that we get data, not that data be rigidly accurate to a clock. + PersistentData.setMostRecentAlarmTime(identifier_string, System.currentTimeMillis()) + // printe("'$identifier_string' turn it on!") + on_action() + val should_turn_off_at_safe = should_turn_off_at + 1000 // add a second to ensure we pass the timer + print("setting OFF TIMER for $identifier_string to $should_turn_off_at_safe") + timer!!.setupSingleAlarmAt(should_turn_off_at_safe, Timer.intent_map[intent_off_string]!!) + // printv("'$identifier_string - on action - ${System.currentTimeMillis() - t1}") + } + } + + /* If there is something with app state logic that should be idempotently checked, stick it + * here. Returns the value used for the current time. @Synchronized because this is the core + * logic loop. Provided potentially-expensive operations, like upload logic, run on anynchronously + * and with reasonably widely separated real-time values, */ + @Synchronized + fun run_all_app_logic(): Long { + PersistentData.appOnRunAllLogic = Date(System.currentTimeMillis()).toLocaleString() + + val now = System.currentTimeMillis() + // ALL of these actions wait until the participant is registered + if (!PersistentData.getIsRegistered()) + return now + + // These are currently syncronous (block) unless they say otherwise, profiling was done + // on a Pixel 6. No-action always measures 0-1ms. + do_new_files_check(now) // always 10s of ms (30-70ms) + do_heartbeat_check(now) // always 10s of ms (30-70ms) + accelerometer_logic(now) + if (omniringListener != null) + omniring_logic(now) + gyro_logic(now) // on action ~20-50ms, off action 10-20ms + gps_logic(now) // on acction <10-20ms, off action ~2ms (yes two) + ambient_audio_logic(now) // asynchronous when stopping because it has to encrypt + do_fcm_upload_logic_check(now) // asynchronous, runs network request on a thread. + do_wifi_logic_check(now) // on action <10-40ms + do_upload_logic_check(now) // asynchronous, runs network request on a thread, single digit ms. + do_new_surveys_check(now) // asynchronous, runs network request on a thread, single digit ms. + do_new_device_settings_check(now) // asynchronous, runs network request on a thread, single digit ms. + do_survey_notifications_check(now) // 1 survey notification <10-30ms. + // highest total time was 159ms, but insufficient data points to be confident. + // printd("run_all_app_logic total time - ${System.currentTimeMillis() - now}") + return now + } + + fun omniring_logic(now: Long) { + if (!PersistentData.getOmniRingEnabled() || omniringListener?.exists == false) + return + + val on_string = getString(R.string.turn_omniring_on) + val off_string = getString(R.string.turn_omniring_off) + val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) + val should_turn_off_at = most_recent_on + PersistentData.getOmniringOnDuration() + val should_turn_on_again_at = + should_turn_off_at + PersistentData.getAccelerometerOffDuration() + do_an_on_off_session_check( + now, + omniringListener!!.running, + should_turn_off_at, + should_turn_on_again_at, + on_string, + off_string, + omniringListener!!.omniring_on_action, + omniringListener!!.omniring_off_action + ) + } + + fun accelerometer_logic(now: Long) { + // accelerometer may not exist, or be disabled for the study, but it does not require permissions. + if (!PersistentData.getAccelerometerEnabled() || !accelerometerListener!!.exists) + return + + // assemble all the variables we need for on-off with duration + val on_string = getString(R.string.turn_accelerometer_on) + val off_string = getString(R.string.turn_accelerometer_off) + val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) + val should_turn_off_at = most_recent_on + PersistentData.getAccelerometerOnDuration() + val should_turn_on_again_at = should_turn_off_at + PersistentData.getAccelerometerOffDuration() + do_an_on_off_session_check( + now, + accelerometerListener!!.running, + should_turn_off_at, + should_turn_on_again_at, + on_string, + off_string, + accelerometerListener!!.accelerometer_on_action, + accelerometerListener!!.accelerometer_off_action + ) + } + + fun gyro_logic(now: Long) { + // gyro may not exist, or be disabled for the study, but it does not require permissions. + if (!PersistentData.getGyroscopeEnabled() || !gyroscopeListener!!.exists) + return + + // assemble all the variables we need for on-off with duration + val on_string = getString(R.string.turn_gyroscope_on) + val off_string = getString(R.string.turn_gyroscope_off) + val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) + val should_turn_off_at = most_recent_on + PersistentData.getGyroscopeOnDuration() + val should_turn_on_again_at = should_turn_off_at + PersistentData.getGyroscopeOffDuration() + do_an_on_off_session_check( + now, + gyroscopeListener!!.running, + should_turn_off_at, + should_turn_on_again_at, + on_string, + off_string, + gyroscopeListener!!.gyro_on_action, + gyroscopeListener!!.gyro_off_action + ) + } + + fun gps_logic(now: Long) { + // GPS (location service) always _exists to a degree_, checked inside the gpsListener, + // but may not be enabled on a study. GPS requires permissions. + if (!PermissionHandler.confirmGps(applicationContext)) + return + + // assemble all the variables we need for on-off with duration + val on_string = getString(R.string.turn_gps_on) + val off_string = getString(R.string.turn_gps_off) + val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) + val should_turn_off_at = most_recent_on + PersistentData.getGpsOnDuration() + val should_turn_on_again_at = should_turn_off_at + PersistentData.getGpsOffDuration() + do_an_on_off_session_check( + now, + gpsListener!!.running, + should_turn_off_at, + should_turn_on_again_at, + on_string, + off_string, + gpsListener!!.gps_on_action, + gpsListener!!.gps_off_action + ) + } + + fun ambient_audio_logic(now: Long) { + // check permissions and enablement + if (!PermissionHandler.confirmAmbientAudioCollection(applicationContext)) + return + + val on_string = getString(R.string.turn_ambient_audio_on) + val off_string = getString(R.string.turn_ambient_audio_off) + val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) + val should_turn_off_at = most_recent_on + PersistentData.getAmbientAudioOnDuration() + val should_turn_on_again_at = should_turn_off_at + PersistentData.getAmbientAudioOffDuration() + + // ambiant audio needs the app context at runtime (we write very consistent code) + val ambient_audio_on = { + AmbientAudioListener.startRecording(applicationContext) + } + do_an_on_off_session_check( + now, + AmbientAudioListener.isCurrentlyRunning, + should_turn_off_at, + should_turn_on_again_at, + on_string, + off_string, + ambient_audio_on, + AmbientAudioListener.ambient_audio_off_action + ) + } + + fun do_wifi_logic_check(now: Long) { + // wifi has permissions and may be disabled on the study + if (!PermissionHandler.confirmWifi(applicationContext)) + return + + val event_string = getString(R.string.run_wifi_log) + val event_frequency = PersistentData.getWifiLogFrequency() + val wifi_do_action = { // wifi will need some attention to convert to kotlin... + WifiListener.scanWifi() + } + do_an_event_session_check(now, event_string, event_frequency, wifi_do_action) + } + + fun do_upload_logic_check(now: Long) { + val upload_string = applicationContext.getString(R.string.upload_data_files_intent) + val periodicity = PersistentData.getUploadDataFilesFrequency() + val do_uploads_action = { + PostRequest.uploadAllFiles() + } + do_an_event_session_check(now, upload_string, periodicity, do_uploads_action) + } + + fun do_fcm_upload_logic_check(now: Long) { + // we can just literally hardcode this one, sendFcmToken is this plus a timer + val event_string = applicationContext.getString(R.string.fcm_upload) + val send_fcm_action = { + val fcm_token = PersistentData.getFCMInstanceID() + if (fcm_token != null) + PostRequest.sendFCMInstanceID(fcm_token) + } + do_an_event_session_check(now, event_string, FCM_TIMER, send_fcm_action) + } + + fun do_heartbeat_check(now: Long) { + val event_string = getString(R.string.heartbeat_intent) + val periodicity = HEARTBEAT_TIMER + val heartbeat_action = { + PostRequest.sendHeartbeat() + } + do_an_event_session_check(now, event_string, periodicity, heartbeat_action) + } + + fun do_new_files_check(now: Long) { + val event_string = getString(R.string.create_new_data_files_intent) + val periodicity = PersistentData.getCreateNewDataFilesFrequency() + val new_files_action = { + TextFileManager.makeNewFilesForEverything() + } + do_an_event_session_check(now, event_string, periodicity, new_files_action) + } + + fun do_new_surveys_check(now: Long) { + val event_string = getString(R.string.check_for_new_surveys_intent) + val periodicity = PersistentData.getCheckForNewSurveysFrequency() + val dowwnload_surveys_action = { + SurveyDownloader.downloadSurveys(applicationContext, null) + } + do_an_event_session_check(now, event_string, periodicity, dowwnload_surveys_action) + } + + fun do_new_device_settings_check(now: Long) { + val event_string = getString(R.string.check_for_new_device_settings_intent) + val download_device_settings_action = { + SetDeviceSettings.dispatchUpdateDeviceSettings() + } + do_an_event_session_check( + now, + event_string, + DEVICE_SETTINGS_UPDATE_PERIODICITY, + download_device_settings_action + ) + } + + /** Checks for the current expected state for survey notifications, and the app state for + * scheduled alarms. */ + fun do_survey_notifications_check(now: Long) { + // val t1 = System.currentTimeMillis() + // var counter = 0 + for (surveyId in PersistentData.getSurveyIds()) { + var app_state_says_on = PersistentData.getSurveyNotificationState(surveyId) + var alarm_in_past = PersistentData.getMostRecentSurveyAlarmTime(surveyId) < now + + // the behavior is that it ... replaces the notification. + if ((app_state_says_on || alarm_in_past) && !isNotificationActive(applicationContext, surveyId)) { + // this calls PersistentData.setSurveyNotificationState + displaySurveyNotification(applicationContext, surveyId) + // counter++ + } + + // TODO: fix this naming mismatch. + // Never call this: + // timer!!.cancelAlarm(Intent(surveyId)) // BAD! + // setMostRecentSurveyAlarmTime is called in Timer.setupSurveyAlarm (when the alarm + // is set in the scheduler logic), e.g. the application state is updated to say that + // the _next_ survey alarm time is at X o'clock - there is a naming mismatch. + + // if there is no alarm set for this survey, set it. + if (!timer!!.alarmIsSet(Intent(surveyId))) + SurveyScheduler.scheduleSurvey(surveyId) + } + // printv("$counter survey notifications took ${System.currentTimeMillis() - t1} ms") + } + + /*########################################################################################## + ############## code related to onStartCommand and binding to an activity ################### + ##########################################################################################*/ + + override fun onBind(arg0: Intent?): IBinder? { + return BackgroundServiceBinder() + } + + /**A public "Binder" class for Activities to access. + * Provides a (safe) handle to the Main Service using the onStartCommand code used in every + * RunningBackgroundServiceActivity */ + inner class BackgroundServiceBinder : Binder() { + val service: MainService + get() = this@MainService + } + + /*############################################################################## + ########################## Android Service Lifecycle ########################### + ##############################################################################*/ + + /** The BackgroundService is meant to be all the time, so we return START_STICKY */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Log.d("BackgroundService onStartCommand", "started with flag " + flags ); + TextFileManager.writeDebugLogStatement( + System.currentTimeMillis().toString() + " started with flag " + flags) + val now = System.currentTimeMillis() + val millisecondsSincePrevious = now - foregroundServiceLastStarted + + // if it has been FOREGROUND_SERVICE_TIMER or longer since the last time we started the + // foreground service notification, start it again. + if (foregroundServiceLastStarted == 0L || millisecondsSincePrevious > FOREGROUND_SERVICE_NOTIFICATION_TIMER) { + val intent_to_start_foreground_service = Intent(applicationContext, MainService::class.java) + val intent_flags = pending_intent_flag_fix(PendingIntent.FLAG_UPDATE_CURRENT) // no flags + val onStartCommandPendingIntent = PendingIntent.getService( + applicationContext, 0, intent_to_start_foreground_service, intent_flags + ) + val notification = Notification.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("Beiwe App") + .setContentText("Beiwe data collection running") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(onStartCommandPendingIntent) + .setTicker("Beiwe data collection running in the background, no action required") + .build() + + // multiple sources recommend an ID of 1 because it works. documentation is very spotty about this + startForeground(1, notification) + foregroundServiceLastStarted = now + } + + PersistentData.serviceStartCommand = Date(System.currentTimeMillis()).toLocaleString() + + // onStartCommand is called every 30 seconds due to repeating high-priority-or-whatever + // alarms, so we will stick a core logic check here. + // printd("run_all_app_logic - onStartCommand") + run_all_app_logic() + + // We want this service to continue running until it is explicitly stopped, so return sticky. + return START_STICKY + // in testing out this restarting behavior for the service it is entirely unclear if changing + // this return will have any observable effect despite the documentation's claim that it does. + // return START_REDELIVER_INTENT; + } + + // the rest of these are ~identical + override fun onTaskRemoved(rootIntent: Intent?) { + // Log.d("BackroundService onTaskRemoved", "onTaskRemoved called with intent: " + rootIntent.toString() ); + TextFileManager.writeDebugLogStatement("onTaskRemoved called with intent: $rootIntent") + PersistentData.serviceOnTaskRemoved = Date(System.currentTimeMillis()).toLocaleString() + restartService() + } + + override fun onUnbind(intent: Intent?): Boolean { + // Log.d("BackroundService onUnbind", "onUnbind called with intent: " + intent.toString() ); + TextFileManager.writeDebugLogStatement("onUnbind called with intent: $intent") + PersistentData.serviceOnUnbind = Date(System.currentTimeMillis()).toLocaleString() + restartService() + return super.onUnbind(intent) + } + + override fun onDestroy() { // Log.w("BackgroundService", "BackgroundService was destroyed."); + // note: this does not run when the service is killed in a task manager, OR when the stopService() function is called from debugActivity. + TextFileManager.writeDebugLogStatement("BackgroundService was destroyed.") + PersistentData.serviceOnDestroy = Date(System.currentTimeMillis()).toLocaleString() + restartService() + super.onDestroy() + } + + override fun onLowMemory() { // Log.w("BackroundService onLowMemory", "Low memory conditions encountered"); + TextFileManager.writeDebugLogStatement("onLowMemory called.") + PersistentData.serviceOnLowMemory = Date(System.currentTimeMillis()).toLocaleString() + restartService() + } + + override fun onTrimMemory(level: Int) { + // Log.w("BackroundService onTrimMemory", "Trim memory conditions encountered"); + TextFileManager.writeDebugLogStatement("onTrimMemory called.") + PersistentData.serviceOnTrimMemory = Date(System.currentTimeMillis()).toLocaleString() + super.onTrimMemory(level) + } + + /** Stops the BackgroundService instance, development tool */ + fun stop() { + if (BuildConfig.APP_IS_BETA) + this.stopSelf() + } + + /** Sets a timer that starts the service if it is not running after a half second. */ + fun restartService() { + val restartServiceIntent = Intent(applicationContext, this.javaClass) + restartServiceIntent.setPackage(packageName) + val restartServicePendingIntent = PendingIntent.getService( + applicationContext, + 1, + restartServiceIntent, + pending_intent_flag_fix(PendingIntent.FLAG_ONE_SHOT) + ) + // kotlin port action turned this into a very weird setter syntax using [] access... + (applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager).set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 500, + restartServicePendingIntent + ) + } + + /** We sometimes need to restart the background service */ + fun exit_and_restart_background_service() { + TextFileManager.writeDebugLogStatement("manually restarting background service") + // if this takes more than 500ms to restart, the app will ~crash... hmm. This is fine. + restartService() + System.exit(0) + } + + // static assets + companion object { + // I guess we need access to this one in static contexts... + public var timer: Timer? = null + + // localHandle is how static scopes access the currently instantiated main service. + // It is to be used ONLY to register new surveys with the running main service, because + // that code needs to be able to update the IntentFilters associated with timerReceiver. + // This is Really Hacky and terrible style, but it is okay because the scheduling code can only ever + // begin to run with an already fully instantiated main service. + var localHandle: MainService? = null + + private var foregroundServiceLastStarted = 0L + + // FIXME: in order to make this non-static we probably need to port PostReqeuest to kotlin + /** create timers that will trigger events throughout the program, and + * register the custom Intents with the controlMessageReceiver. */ + @JvmStatic + fun registerTimers(applicationContext: Context) { + timer = Timer(localHandle!!) + val filter = IntentFilter() + filter.addAction(applicationContext.getString(R.string.turn_accelerometer_off)) + filter.addAction(applicationContext.getString(R.string.turn_accelerometer_on)) + filter.addAction(applicationContext.getString(R.string.turn_ambient_audio_off)) + filter.addAction(applicationContext.getString(R.string.turn_ambient_audio_on)) + filter.addAction(applicationContext.getString(R.string.turn_gyroscope_on)) + filter.addAction(applicationContext.getString(R.string.turn_gyroscope_off)) + filter.addAction(applicationContext.getString(R.string.turn_bluetooth_on)) + filter.addAction(applicationContext.getString(R.string.turn_bluetooth_off)) + filter.addAction(applicationContext.getString(R.string.turn_omniring_on)) + filter.addAction(applicationContext.getString(R.string.turn_gps_on)) + filter.addAction(applicationContext.getString(R.string.turn_gps_off)) + filter.addAction(applicationContext.getString(R.string.signout_intent)) + filter.addAction(applicationContext.getString(R.string.voice_recording)) + filter.addAction(applicationContext.getString(R.string.run_wifi_log)) + filter.addAction(applicationContext.getString(R.string.upload_data_files_intent)) + filter.addAction(applicationContext.getString(R.string.create_new_data_files_intent)) + filter.addAction(applicationContext.getString(R.string.check_for_new_surveys_intent)) + filter.addAction(applicationContext.getString(R.string.check_for_sms_enabled)) + filter.addAction(applicationContext.getString(R.string.check_for_call_log_enabled)) + filter.addAction(applicationContext.getString(R.string.check_if_ambient_audio_recording_is_enabled)) + filter.addAction(applicationContext.getString(R.string.fcm_upload)) + filter.addAction(applicationContext.getString(R.string.check_for_new_device_settings_intent)) + filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION) + filter.addAction("crashBeiwe") + filter.addAction("enterANR") + + for (surveyId in PersistentData.getSurveyIds()) { + filter.addAction(surveyId) + } + applicationContext.registerReceiver(localHandle!!.timerReceiver, filter) + } + + /**Refreshes the logout timer. + * This function has a THEORETICAL race condition, where the BackgroundService is not fully instantiated by a session activity, + * in this case we log an error to the debug log, print the error, and then wait for it to crash. In testing on a (much) older + * version of the app we would occasionally see the error message, but we have never (august 10 2015) actually seen the app crash + * inside this code. */ + fun startAutomaticLogoutCountdownTimer() { + if (timer == null) { + Log.e("bacgroundService", "timer is null, BackgroundService may be about to crash, the Timer was null when the BackgroundService was supposed to be fully instantiated.") + TextFileManager.writeDebugLogStatement("our not-quite-race-condition encountered, Timer was null when the BackgroundService was supposed to be fully instantiated") + } + timer!!.setupExactSingleAlarm(PersistentData.getTimeBeforeAutoLogout(), Timer.signoutIntent) + PersistentData.loginOrRefreshLogin() + } + + /** cancels the signout timer */ + fun clearAutomaticLogoutCountdownTimer() { + timer!!.cancelAlarm(Timer.signoutIntent) + } + + /** The Timer requires the BackgroundService in order to create alarms, hook into that functionality here. */ + @JvmStatic + fun setSurveyAlarm(surveyId: String?, alarmTime: Calendar?) { + timer!!.startSurveyAlarm(surveyId!!, alarmTime!!) + } + + @JvmStatic + fun cancelSurveyAlarm(surveyId: String?) { + timer!!.cancelAlarm(Intent(surveyId)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index b79ca724..bba08fb5 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -14,6 +14,7 @@ import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.content.Intent import android.content.pm.PackageManager +import android.os.Binder import android.os.IBinder import android.util.Log import org.beiwe.app.PermissionHandler @@ -23,7 +24,7 @@ import java.nio.ByteOrder import java.util.UUID class OmniringListener : Service() { - private var bluetoothManager: BluetoothManager = getSystemService(BluetoothManager::class.java) + private var bluetoothManager: BluetoothManager? = null private var bluetoothAdapter: BluetoothAdapter? = null private var bluetoothGatt: BluetoothGatt? = null private var connectionState = STATE_DISCONNECTED @@ -32,7 +33,7 @@ class OmniringListener : Service() { private var omniringDataCharacteristic: BluetoothGattCharacteristic? = null private var lineCount = 0 var running = false - var exists: Boolean = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) + var exists: Boolean = false companion object { private const val TAG = "OmniringListener" @@ -48,8 +49,15 @@ class OmniringListener : Service() { } + private val binder = LocalBinder() + + inner class LocalBinder : Binder() { + // Return this instance of LocalService so clients can call public methods. + fun getService(): OmniringListener = this@OmniringListener + } + override fun onBind(intent: Intent?): IBinder? { - return null + return binder } private fun unpackFByteArray(byteArray: ByteArray): Float { @@ -111,6 +119,7 @@ class OmniringListener : Service() { TAG, "subscribeToNotifications: omniring data characteristic found, enabling notifications" ) + enableNotification() } } } @@ -148,10 +157,9 @@ class OmniringListener : Service() { lineCount = 0 } - TextFileManager.getOmniRingLog().writeEncrypted( - System.currentTimeMillis().toString() + "," + - decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") - ) + val data = decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") + TextFileManager.getOmniRingLog() + .writeEncrypted(System.currentTimeMillis().toString() + "," + data) lineCount++ } @@ -187,7 +195,7 @@ class OmniringListener : Service() { val device = result.device if ( PermissionHandler.checkBluetoothPermissions(this@OmniringListener) && - device.name.startsWith("PPG_Ring") + device.name?.startsWith("PPG_Ring") == true ) { omniringDevice = device if (device.bondState != BluetoothAdapter.STATE_CONNECTED) @@ -197,13 +205,13 @@ class OmniringListener : Service() { override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) - Log.e("Bluetooth", "Scan failed with error code: $errorCode") + Log.e(TAG, "Scan failed with error code: $errorCode") } } private fun initialize(): Boolean { Log.d(TAG, "initialize: init omniring") - bluetoothAdapter = bluetoothManager.adapter + bluetoothAdapter = bluetoothManager?.adapter if (bluetoothAdapter == null) { Log.e(TAG, "Unable to obtain a BluetoothAdapter.") return false @@ -213,15 +221,19 @@ class OmniringListener : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager + exists = packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) initialize() return START_STICKY } val omniring_on_action: () -> Unit = { + Log.d(TAG, "omniring: on action called") if (bluetoothAdapter?.isEnabled == false) { Log.e(TAG, "Bluetooth is disabled.") } else { if (omniringDevice == null) { + Log.d(TAG, "starting scan") bluetoothLeScanner?.startScan(scanCallback) } @@ -236,6 +248,7 @@ class OmniringListener : Service() { } val omniring_off_action: () -> Unit = { + Log.d(TAG, "omniring: off action called") disableNotification() } From f674cfdac6654383ed946a730a692a7a1ef2609b Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 14 Oct 2024 14:10:01 -0500 Subject: [PATCH 19/28] chore: fix bluetooth permission Signed-off-by: Reyva Babtista (cherry picked from commit 2f20432ad081a00b10b61c534e791430d85802e5) --- app/src/main/AndroidManifest.xml | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d35ba681..6c64fa2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,20 +51,31 @@ - - - - - - - + - - - + + + + Date: Mon, 14 Oct 2024 14:10:22 -0500 Subject: [PATCH 20/28] chore: downgrade gradle Signed-off-by: Reyva Babtista (cherry picked from commit 4bf42de7f9b6ab40324582842978c23454d6d38d) --- gradle/wrapper/gradle-wrapper.properties | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e..c856fecc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Wed Nov 11 20:20:21 EST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -networkTimeout=10000 -validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip From a9bbf42c5f90dbdcb3daf724a58555d5d8a540e6 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 25 Nov 2024 14:58:40 -0600 Subject: [PATCH 21/28] refactor: add temperature value, change device name --- .../main/java/org/beiwe/app/listeners/OmniringListener.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index bba08fb5..6a4f125f 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -45,7 +45,7 @@ class OmniringListener : Service() { @JvmField var omniring_header = - "timestamp,PPG_red,PPG_IR,PPG_Green,IMU_Accel_x,IMU_Accel_y,IMU_Accel_z,IMU_Gyro_x,IMU_Gyro_y,IMU_Gyro_z,IMU_Mag_x,IMU_Mag_y,IMU_Mag_z,timestamp" + "timestamp,PPG_red,PPG_IR,PPG_Green,IMU_Accel_x,IMU_Accel_y,IMU_Accel_z,IMU_Gyro_x,IMU_Gyro_y,IMU_Gyro_z,IMU_Mag_x,IMU_Mag_y,IMU_Mag_z,temperature,timestamp" } @@ -129,7 +129,10 @@ class OmniringListener : Service() { override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) if (running) { + Log.d(TAG, "onServicesDiscovered: omniring running, enabling notifications") findOmniringCharacteristic() + } else { + Log.d(TAG, "onServicesDiscovered: omniring not running, not enabling notifications") } } @@ -195,7 +198,7 @@ class OmniringListener : Service() { val device = result.device if ( PermissionHandler.checkBluetoothPermissions(this@OmniringListener) && - device.name?.startsWith("PPG_Ring") == true + device.name?.startsWith("OmniRing") == true ) { omniringDevice = device if (device.bondState != BluetoothAdapter.STATE_CONNECTED) From 5fa3bad0f29bef9ea202d6907fc01616ce5cb4ca Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 2 Dec 2024 10:22:51 -0600 Subject: [PATCH 22/28] fix: omniring listener logic --- .../main/java/org/beiwe/app/MainService.kt | 3 +- .../beiwe/app/listeners/OmniringListener.kt | 33 +++++++++---------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index 23d26803..ddcabea2 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -24,7 +24,6 @@ import org.beiwe.app.PermissionHandler.checkBluetoothPermissions import org.beiwe.app.PermissionHandler.confirmBluetooth import org.beiwe.app.PermissionHandler.confirmCallLogging import org.beiwe.app.PermissionHandler.confirmTexts -import org.beiwe.app.listeners.* import org.beiwe.app.listeners.AccelerometerListener import org.beiwe.app.listeners.AmbientAudioListener import org.beiwe.app.listeners.BluetoothListener @@ -607,7 +606,7 @@ class MainService : Service() { should_turn_off_at + PersistentData.getAccelerometerOffDuration() do_an_on_off_session_check( now, - omniringListener!!.running, + omniringListener!!.isOnState, should_turn_off_at, should_turn_on_again_at, on_string, diff --git a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt index 6a4f125f..02381535 100644 --- a/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt +++ b/app/src/main/java/org/beiwe/app/listeners/OmniringListener.kt @@ -32,7 +32,7 @@ class OmniringListener : Service() { private var omniringDevice: BluetoothDevice? = null private var omniringDataCharacteristic: BluetoothGattCharacteristic? = null private var lineCount = 0 - var running = false + var isOnState = false var exists: Boolean = false companion object { @@ -86,7 +86,6 @@ class OmniringListener : Service() { characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) descriptor.value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE bluetoothGatt?.writeDescriptor(descriptor) - running = false } // Enable notifications @@ -99,7 +98,6 @@ class OmniringListener : Service() { characteristic.getDescriptor(UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE bluetoothGatt?.writeDescriptor(descriptor) - running = true } private fun getSupportedGattServices(): List? { @@ -119,7 +117,8 @@ class OmniringListener : Service() { TAG, "subscribeToNotifications: omniring data characteristic found, enabling notifications" ) - enableNotification() + if (isOnState) enableNotification() else disableNotification() + return } } } @@ -128,12 +127,7 @@ class OmniringListener : Service() { private val bluetoothGattCallback = object : BluetoothGattCallback() { override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) - if (running) { - Log.d(TAG, "onServicesDiscovered: omniring running, enabling notifications") - findOmniringCharacteristic() - } else { - Log.d(TAG, "onServicesDiscovered: omniring not running, not enabling notifications") - } + findOmniringCharacteristic() } override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { @@ -161,6 +155,7 @@ class OmniringListener : Service() { } val data = decodeByteData(characteristic?.value ?: byteArrayOf()).joinToString(",") + Log.d(TAG, "onCharacteristicChanged: $data") TextFileManager.getOmniRingLog() .writeEncrypted(System.currentTimeMillis().toString() + "," + data) lineCount++ @@ -195,10 +190,14 @@ class OmniringListener : Service() { private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: android.bluetooth.le.ScanResult) { super.onScanResult(callbackType, result) + if (omniringDevice != null && omniringDevice?.bondState == BluetoothAdapter.STATE_CONNECTED) { + return + } + val device = result.device if ( PermissionHandler.checkBluetoothPermissions(this@OmniringListener) && - device.name?.startsWith("OmniRing") == true + device.name?.startsWith("OmniRing") == true // TODO: refactor this condition to be dynamically set by backend ) { omniringDevice = device if (device.bondState != BluetoothAdapter.STATE_CONNECTED) @@ -232,25 +231,25 @@ class OmniringListener : Service() { val omniring_on_action: () -> Unit = { Log.d(TAG, "omniring: on action called") + isOnState = true if (bluetoothAdapter?.isEnabled == false) { Log.e(TAG, "Bluetooth is disabled.") } else { if (omniringDevice == null) { Log.d(TAG, "starting scan") bluetoothLeScanner?.startScan(scanCallback) - } - - if (omniringDevice != null && connectionState == STATE_DISCONNECTED) { + } else if (connectionState == STATE_DISCONNECTED) { connect(omniringDevice?.address ?: "") - } - - if (omniringDataCharacteristic != null) { + } else if (omniringDataCharacteristic == null) { + findOmniringCharacteristic() + } else { enableNotification() } } } val omniring_off_action: () -> Unit = { + isOnState = false Log.d(TAG, "omniring: off action called") disableNotification() } From 449a07e13e070c2821b72ead56277e53a750d2ad Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 2 Dec 2024 11:00:46 -0600 Subject: [PATCH 23/28] feat: add UTC to current timezone timestamp converter --- app/src/main/java/org/beiwe/app/utils.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/java/org/beiwe/app/utils.kt b/app/src/main/java/org/beiwe/app/utils.kt index 15fde57f..43411e8f 100644 --- a/app/src/main/java/org/beiwe/app/utils.kt +++ b/app/src/main/java/org/beiwe/app/utils.kt @@ -1,8 +1,12 @@ package org.beiwe.app import android.app.PendingIntent +import android.icu.text.SimpleDateFormat +import android.icu.util.TimeZone import android.os.Build import android.util.Log +import java.util.Date +import java.util.Locale // This file is a location for new static functions, further factoring into files will occur when length of file becomes a problem. @@ -53,4 +57,14 @@ fun pending_intent_flag_fix(flag: Int): Int { return (PendingIntent.FLAG_IMMUTABLE or flag) else return flag +} + +/** + * Converts a UTC timestamp to a human-readable date and time string, based on current device's timezone. + */ +fun convertTimestamp(utcTimestamp: Long): String { + val date = Date(utcTimestamp) + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + dateFormat.timeZone = TimeZone.getDefault() + return dateFormat.format(date) } \ No newline at end of file From f7558960db780f41a0223b8d711f6e985628f86c Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 2 Dec 2024 11:01:00 -0600 Subject: [PATCH 24/28] fix: make on-off logging clearer --- .../main/java/org/beiwe/app/MainService.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index b49bbca3..02d94c10 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -463,14 +463,14 @@ class MainService : Service() { * check. (we pad with an extra second to ensure that check hits an inflection point where * action is required.) */ fun do_an_on_off_session_check( - now: Long, - is_running: Boolean, - should_turn_off_at: Long, - should_turn_on_again_at: Long, - identifier_string: String, - intent_off_string: String, - on_action: () -> Unit, - off_action: () -> Unit, + now: Long, + is_running: Boolean, + should_turn_off_at: Long, + should_turn_on_at: Long, + intent_on_string: String, + intent_off_string: String, + on_action: () -> Unit, + off_action: () -> Unit, ) { // val t1 = System.currentTimeMillis() if (is_running && now <= should_turn_off_at) { @@ -483,28 +483,28 @@ class MainService : Service() { if (is_running && should_turn_off_at < now) { // printi("'$identifier_string' time to turn off") off_action() - val should_turn_on_again_at_safe = should_turn_on_again_at + 1000 // add a second to ensure we pass the timer - print("setting ON TIMER for $identifier_string to $should_turn_on_again_at_safe") - timer!!.setupSingleAlarmAt(should_turn_on_again_at_safe, Timer.intent_map[identifier_string]!!) + val should_turn_on_at_safe = should_turn_on_at + 1000 // add a second to ensure we pass the timer + print("Sensor listener service turned off. Next $intent_on_string is scheduled to ${convertTimestamp(should_turn_on_at_safe)}") + timer!!.setupSingleAlarmAt(should_turn_on_at_safe, Timer.intent_map[intent_on_string]!!) // printv("'$identifier_string - turn off - ${System.currentTimeMillis() - t1}") } // not_running, should turn on is still in the future, do nothing - if (!is_running && should_turn_on_again_at >= now) { + if (!is_running && should_turn_on_at >= now) { // printw("'$identifier_string' correctly off") // printv("'$identifier_string - correctly off - ${System.currentTimeMillis() - t1}") return } // not running, should be on, (on is in the past) - if (!is_running && should_turn_on_again_at < now) { + if (!is_running && should_turn_on_at < now) { // always get the current time, the now value could be stale - unlikely but possible we // care that we get data, not that data be rigidly accurate to a clock. - PersistentData.setMostRecentAlarmTime(identifier_string, System.currentTimeMillis()) + PersistentData.setMostRecentAlarmTime(intent_on_string, System.currentTimeMillis()) // printe("'$identifier_string' turn it on!") on_action() val should_turn_off_at_safe = should_turn_off_at + 1000 // add a second to ensure we pass the timer - print("setting OFF TIMER for $identifier_string to $should_turn_off_at_safe") + print("Sensor listener service turned on. Next $intent_off_string is scheduled to ${convertTimestamp(should_turn_off_at_safe)}") timer!!.setupSingleAlarmAt(should_turn_off_at_safe, Timer.intent_map[intent_off_string]!!) // printv("'$identifier_string - on action - ${System.currentTimeMillis() - t1}") } @@ -776,7 +776,7 @@ class MainService : Service() { startForeground(1, notification) foregroundServiceLastStarted = now } - + PersistentData.serviceStartCommand = Date(System.currentTimeMillis()).toLocaleString() // onStartCommand is called every 30 seconds due to repeating high-priority-or-whatever From 7587a8d1154fd6ddb54ef8d8ba9154aab36e0a6c Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 2 Dec 2024 14:20:19 -0600 Subject: [PATCH 25/28] fix: omniring on off logic --- .../main/java/org/beiwe/app/MainService.kt | 4 +-- .../org/beiwe/app/storage/PersistentData.kt | 36 +++---------------- .../beiwe/app/storage/SetDeviceSettings.kt | 5 ++- 3 files changed, 9 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index 17dc03c1..c4f725a1 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -601,9 +601,9 @@ class MainService : Service() { val on_string = getString(R.string.turn_omniring_on) val off_string = getString(R.string.turn_omniring_off) val most_recent_on = PersistentData.getMostRecentAlarmTime(on_string) - val should_turn_off_at = most_recent_on + PersistentData.getOmniringOnDuration() + val should_turn_off_at = most_recent_on + PersistentData.getOmniRingOnDuration() val should_turn_on_again_at = - should_turn_off_at + PersistentData.getAccelerometerOffDuration() + should_turn_off_at + PersistentData.getOmniRingOffDuration() do_an_on_off_session_check( now, omniringListener!!.isOnState, diff --git a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt index ebbf9628..408bfcc4 100644 --- a/app/src/main/java/org/beiwe/app/storage/PersistentData.kt +++ b/app/src/main/java/org/beiwe/app/storage/PersistentData.kt @@ -53,9 +53,8 @@ const val GYROSCOPE_FREQUENCY = "gyro_frequency" const val BLUETOOTH_ON_SECONDS = "bluetooth_on_duration_seconds" const val BLUETOOTH_TOTAL_SECONDS = "bluetooth_total_duration_seconds" const val BLUETOOTH_GLOBAL_OFFSET_SECONDS = "bluetooth_global_offset_seconds" +const val OMNIRING_OFF_SECONDS = "omniring_off_duration_seconds" const val OMNIRING_ON_SECONDS = "omniring_on_duration_seconds" -const val OMNIRING_TOTAL_SECONDS = "omniring_total_duration_seconds" -const val OMNIRING_GLOBAL_OFFSET_SECONDS = "omniring_global_offset_seconds" const val CHECK_FOR_NEW_SURVEYS_FREQUENCY_SECONDS = "check_for_new_surveys_frequency_seconds" const val CREATE_NEW_DATA_FILES_FREQUENCY_SECONDS = "create_new_data_files_frequency_seconds" const val GPS_OFF_SECONDS = "gps_off_duration_seconds" @@ -395,35 +394,10 @@ object PersistentData { @JvmStatic fun setBluetoothOnDuration(seconds: Long) { putCommit(BLUETOOTH_ON_SECONDS, seconds) } @JvmStatic fun getBluetoothTotalDuration(): Long { return 1000L * pref.getLong(BLUETOOTH_TOTAL_SECONDS, (5 * 60).toLong()) } @JvmStatic fun setBluetoothTotalDuration(seconds: Long) { putCommit(BLUETOOTH_TOTAL_SECONDS, seconds) } - @JvmStatic - fun getOmniringGlobalOffset(): Long { - return 1000L * pref.getLong(OMNIRING_GLOBAL_OFFSET_SECONDS, (0 * 60).toLong()) - } - - @JvmStatic - fun setOmniringGlobalOffset(seconds: Long) { - putCommit(OMNIRING_GLOBAL_OFFSET_SECONDS, seconds) - } - - @JvmStatic - fun getOmniringOnDuration(): Long { - return 1000L * pref.getLong(OMNIRING_ON_SECONDS, (1 * 60).toLong()) - } - - @JvmStatic - fun setOmniringOnDuration(seconds: Long) { - putCommit(OMNIRING_ON_SECONDS, seconds) - } - - @JvmStatic - fun getOmniringTotalDuration(): Long { - return 1000L * pref.getLong(OMNIRING_TOTAL_SECONDS, (5 * 60).toLong()) - } - - @JvmStatic - fun setOmniringTotalDuration(seconds: Long) { - putCommit(OMNIRING_TOTAL_SECONDS, seconds) - } + @JvmStatic fun getOmniRingOffDuration(): Long { return 1000L * pref.getLong(OMNIRING_OFF_SECONDS, 10) } + @JvmStatic fun setOmniRingOffDuration(seconds: Long) { putCommit(OMNIRING_OFF_SECONDS, seconds) } + @JvmStatic fun getOmniRingOnDuration(): Long { return 1000L * pref.getLong(OMNIRING_ON_SECONDS, (10 * 60).toLong()) } + @JvmStatic fun setOmniRingOnDuration(seconds: Long) { putCommit(OMNIRING_ON_SECONDS, seconds) } @JvmStatic fun getCheckForNewSurveysFrequency(): Long { return 1000L * pref.getLong(CHECK_FOR_NEW_SURVEYS_FREQUENCY_SECONDS, (24 * 60 * 60).toLong()) } @JvmStatic fun setCheckForNewSurveysFrequency(seconds: Long) { putCommit(CHECK_FOR_NEW_SURVEYS_FREQUENCY_SECONDS, seconds) } @JvmStatic fun getCreateNewDataFilesFrequency(): Long { return 1000L * pref.getLong(CREATE_NEW_DATA_FILES_FREQUENCY_SECONDS, (15 * 60).toLong()) } diff --git a/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt b/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt index 779a36b2..4905160c 100644 --- a/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt +++ b/app/src/main/java/org/beiwe/app/storage/SetDeviceSettings.kt @@ -52,9 +52,8 @@ object SetDeviceSettings { PersistentData.setBluetoothOnDuration(deviceSettings.getLong("bluetooth_on_duration_seconds")) PersistentData.setBluetoothTotalDuration(deviceSettings.getLong("bluetooth_total_duration_seconds")) PersistentData.setBluetoothGlobalOffset(deviceSettings.getLong("bluetooth_global_offset_seconds")) - PersistentData.setBluetoothOnDuration(deviceSettings.getLong("omniring_on_duration_seconds")) - PersistentData.setBluetoothTotalDuration(deviceSettings.getLong("omniring_total_duration_seconds")) - PersistentData.setBluetoothGlobalOffset(deviceSettings.getLong("omniring_global_offset_seconds")) + PersistentData.setOmniRingOffDuration(deviceSettings.getLong("omniring_off_duration_seconds")) + PersistentData.setOmniRingOnDuration(deviceSettings.getLong("omniring_on_duration_seconds")) PersistentData.setCheckForNewSurveysFrequency(deviceSettings.getLong("check_for_new_surveys_frequency_seconds")) PersistentData.setCreateNewDataFilesFrequency(deviceSettings.getLong("create_new_data_files_frequency_seconds")) PersistentData.setGpsOffDuration(deviceSettings.getLong("gps_off_duration_seconds")) From 05fb7fd23570b294a883c772ea9eda3a28a7b9e0 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 2 Dec 2024 14:33:38 -0600 Subject: [PATCH 26/28] refactor: separate bluetooth & omniring logic --- .../main/java/org/beiwe/app/MainService.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index c4f725a1..24a9f9ea 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -23,6 +23,7 @@ import io.sentry.dsn.InvalidDsnException import org.beiwe.app.PermissionHandler.checkBluetoothPermissions import org.beiwe.app.PermissionHandler.confirmBluetooth import org.beiwe.app.PermissionHandler.confirmCallLogging +import org.beiwe.app.PermissionHandler.confirmOmniRing import org.beiwe.app.PermissionHandler.confirmTexts import org.beiwe.app.listeners.AccelerometerListener import org.beiwe.app.listeners.AmbientAudioListener @@ -157,7 +158,10 @@ class MainService : Service() { // Bluetooth, wifi, gps, calls, and texts need permissions if (confirmBluetooth(applicationContext)) - initializeBluetoothAndOmniring() + initializeBluetooth() + + if (confirmOmniRing(applicationContext)) + initializeOmniRing() if (confirmTexts(applicationContext)) { startSmsSentLogger() @@ -208,7 +212,7 @@ class MainService : Service() { * Bluetooth has several checks to make sure that it actually exists on the device with the * capabilities we need. Checking for Bluetooth LE is necessary because it is an optional * extension to Bluetooth 4.0. */ - fun initializeBluetoothAndOmniring() { + fun initializeBluetooth() { // Note: the Bluetooth listener is a BroadcastReceiver, which means it must have a 0-argument // constructor in order for android to instantiate it on broadcast receipts. The following // check must be made, but it requires a Context that we cannot pass into the @@ -227,6 +231,17 @@ class MainService : Service() { TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_1) } } + } else { + if (PersistentData.getBluetoothEnabled()) { + TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_2) + Log.w("MainS bluetooth init", BLLUETOOTH_MESSAGE_2) + } + bluetoothListener = null + } + } + + fun initializeOmniRing() { + if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { if (PersistentData.getOmniRingEnabled()) { val intent = Intent(this, OmniringListener::class.java).also { intent -> bindService(intent, connection, Context.BIND_AUTO_CREATE) @@ -235,15 +250,10 @@ class MainService : Service() { startService(intent) } } else { - if (PersistentData.getBluetoothEnabled()) { - TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_2) - Log.w("MainS bluetooth init", BLLUETOOTH_MESSAGE_2) - } if (PersistentData.getOmniRingEnabled()) { TextFileManager.writeDebugLogStatement(BLLUETOOTH_MESSAGE_2) Log.w("MainS omniring init", BLLUETOOTH_MESSAGE_2) } - bluetoothListener = null omniringListener = null } } From cddf7946a428070eb8c3e4a80dab8ec68cec4081 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Mon, 2 Dec 2024 14:34:01 -0600 Subject: [PATCH 27/28] refactor: separate bluetooth & omniring permission logic --- app/src/main/java/org/beiwe/app/PermissionHandler.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/beiwe/app/PermissionHandler.kt b/app/src/main/java/org/beiwe/app/PermissionHandler.kt index b0e6c3ba..cc7ea35d 100644 --- a/app/src/main/java/org/beiwe/app/PermissionHandler.kt +++ b/app/src/main/java/org/beiwe/app/PermissionHandler.kt @@ -243,6 +243,11 @@ object PermissionHandler { return PersistentData.getBluetoothEnabled() && checkBluetoothPermissions(context) } + @JvmStatic + fun confirmOmniRing(context: Context): Boolean { + return PersistentData.getOmniRingEnabled() && checkBluetoothPermissions(context) + } + @JvmStatic fun confirmAmbientAudioCollection(context: Context): Boolean { return PersistentData.getAmbientAudioEnabled() && checkAccessRecordAudio(context) @@ -264,7 +269,7 @@ object PermissionHandler { if (!checkAccessCoarseLocation(context)) return Manifest.permission.ACCESS_COARSE_LOCATION if (!checkAccessFineLocation(context)) return Manifest.permission.ACCESS_FINE_LOCATION } - if (PersistentData.getBluetoothEnabled()) { + if (PersistentData.getBluetoothEnabled() || PersistentData.getOmniRingEnabled()) { // android versions below 12 use permission.BLUETOOTH and permission.BLUETOOTH_ADMIN, // 12+ uses permission.BLUETOOTH_CONNECT and permission.BLUETOOTH_SCAN if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { From eac92bf1c83764048967c192e615fc38b4f59e53 Mon Sep 17 00:00:00 2001 From: Reyva Babtista Date: Thu, 5 Dec 2024 17:03:19 -0600 Subject: [PATCH 28/28] refactor: logging messages --- app/src/main/java/org/beiwe/app/MainService.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/beiwe/app/MainService.kt b/app/src/main/java/org/beiwe/app/MainService.kt index 02d94c10..260685f3 100644 --- a/app/src/main/java/org/beiwe/app/MainService.kt +++ b/app/src/main/java/org/beiwe/app/MainService.kt @@ -474,25 +474,21 @@ class MainService : Service() { ) { // val t1 = System.currentTimeMillis() if (is_running && now <= should_turn_off_at) { - // printw("'$identifier_string' is running, not time to turn of") - // printv("'$identifier_string - is running - ${System.currentTimeMillis() - t1}") + print("Sensor listener service is running. Next $intent_off_string is scheduled to ${convertTimestamp(should_turn_off_at)}") return } // running, should be off, off is in the past if (is_running && should_turn_off_at < now) { - // printi("'$identifier_string' time to turn off") off_action() val should_turn_on_at_safe = should_turn_on_at + 1000 // add a second to ensure we pass the timer - print("Sensor listener service turned off. Next $intent_on_string is scheduled to ${convertTimestamp(should_turn_on_at_safe)}") timer!!.setupSingleAlarmAt(should_turn_on_at_safe, Timer.intent_map[intent_on_string]!!) - // printv("'$identifier_string - turn off - ${System.currentTimeMillis() - t1}") + print("Sensor listener service turned off. Next $intent_on_string is scheduled to ${convertTimestamp(should_turn_on_at_safe)}") } // not_running, should turn on is still in the future, do nothing if (!is_running && should_turn_on_at >= now) { - // printw("'$identifier_string' correctly off") - // printv("'$identifier_string - correctly off - ${System.currentTimeMillis() - t1}") + print("Sensor listener service is off. Next $intent_on_string is scheduled to ${convertTimestamp(should_turn_on_at)}") return } @@ -501,12 +497,10 @@ class MainService : Service() { // always get the current time, the now value could be stale - unlikely but possible we // care that we get data, not that data be rigidly accurate to a clock. PersistentData.setMostRecentAlarmTime(intent_on_string, System.currentTimeMillis()) - // printe("'$identifier_string' turn it on!") on_action() val should_turn_off_at_safe = should_turn_off_at + 1000 // add a second to ensure we pass the timer - print("Sensor listener service turned on. Next $intent_off_string is scheduled to ${convertTimestamp(should_turn_off_at_safe)}") timer!!.setupSingleAlarmAt(should_turn_off_at_safe, Timer.intent_map[intent_off_string]!!) - // printv("'$identifier_string - on action - ${System.currentTimeMillis() - t1}") + print("Sensor listener service turned on. Next $intent_off_string is scheduled to ${convertTimestamp(should_turn_off_at_safe)}") } }