From fd76cd8ff53df9d061cdbbc65fab3a46f84482d3 Mon Sep 17 00:00:00 2001 From: Stefan Zollinger Date: Wed, 10 Apr 2024 10:07:46 +0200 Subject: [PATCH] feat: store and export measurements locally --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 22 +++ .../com/example/sensortestingapp/Database.kt | 161 ++++++++++++++++++ .../example/sensortestingapp/DecoderIaq.kt | 4 + .../example/sensortestingapp/KirbyDevice.kt | 4 + .../example/sensortestingapp/MainActivity.kt | 62 ++++++- app/src/main/res/menu/menu_main.xml | 4 +- app/src/main/res/values/strings.xml | 2 +- app/src/main/res/xml/provider_paths.xml | 10 ++ 9 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/example/sensortestingapp/Database.kt create mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/app/build.gradle b/app/build.gradle index b8ed24d..082476d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,6 +58,8 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' implementation 'androidx.recyclerview:recyclerview:1.2.0' + implementation 'com.google.code.gson:gson:2.8.8' + implementation group: 'commons-codec', name: 'commons-codec', version: '1.16.0' implementation "com.android.volley:volley:1.2.1" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d4ba815..ea44604 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,17 @@ android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> + + + + + + + + + @@ -50,6 +61,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/sensortestingapp/Database.kt b/app/src/main/java/com/example/sensortestingapp/Database.kt new file mode 100644 index 0000000..b03ac3b --- /dev/null +++ b/app/src/main/java/com/example/sensortestingapp/Database.kt @@ -0,0 +1,161 @@ +package com.example.sensortestingapp + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.net.Uri +import android.provider.BaseColumns +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + + +object LoggerContract { + // Table contents are grouped together in an anonymous object. + object LogEntry : BaseColumns { + const val TABLE_NAME = "measurements" + const val COLUMN_NAME_TS = "ts" + const val COLUMN_NAME_PAYLOAD = "payload" + + } + + private const val SQL_CREATE_ENTRIES = + "CREATE TABLE ${LogEntry.TABLE_NAME} (" + + "${BaseColumns._ID} INTEGER PRIMARY KEY," + + "${LogEntry.COLUMN_NAME_TS} TEXT," + + "${LogEntry.COLUMN_NAME_PAYLOAD} TEXT)" + + private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${LogEntry.TABLE_NAME}" + + class LoggerDbHelper(context: Context) : + SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(SQL_CREATE_ENTRIES) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // This database is only a cache for online data, so its upgrade policy is + // to simply to discard the data and start over + db.execSQL(SQL_DELETE_ENTRIES) + onCreate(db) + } + + override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + onUpgrade(db, oldVersion, newVersion) + } + + + companion object { + // If you change the database schema, you must increment the database version. + const val DATABASE_VERSION = 2 + const val DATABASE_NAME = "Logger.db" + } + } + + class LoggerDb(context: Context) { + + private val dbHelper = LoggerDbHelper(context) + private val dbWrite = dbHelper.writableDatabase + private val dbRead = dbHelper.writableDatabase + val context: Context = context + + fun writeLog(payload: Any): Long? { + val gson = Gson() + val jsonString = gson.toJson(payload) + + val date = LocalDateTime.now() + val ts = date.format(DateTimeFormatter.ISO_DATE_TIME) + + Log.i("Database", jsonString) + + val values = ContentValues().apply { + put(LogEntry.COLUMN_NAME_TS, ts) + put(LogEntry.COLUMN_NAME_PAYLOAD, jsonString) + } + + return dbWrite?.insert(LogEntry.TABLE_NAME, null, values) + } + + + fun exportToUri(uri: Uri) { + val projection = + arrayOf(BaseColumns._ID, LogEntry.COLUMN_NAME_PAYLOAD, LogEntry.COLUMN_NAME_TS) + + val sortOrder = "${BaseColumns._ID} ASC" + + val cursor = dbRead.query( + LogEntry.TABLE_NAME, // The table to query + projection, // The array of columns to return (pass null to get all) + null, // The columns for the WHERE clause + null, // The values for the WHERE clause + null, // don't group the rows + null, // don't filter by row groups + sortOrder // The sort order + ) + + try { + + val gson = Gson() + val type = object : TypeToken>() {}.type + var headerWritten = false + val sep = "," + + context.contentResolver.openOutputStream(uri)?.use { writer -> + val newLine = '\n' + + with(cursor) { + while (moveToNext()) { + val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS)) + val storedField = + getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD)) + + try { + val payload: HashMap = gson.fromJson(storedField, type) + + if (!headerWritten) { + val headerRow = + "timestamp" + sep + payload.keys.joinToString(sep) + newLine + writer.write(headerRow.toByteArray()) + + headerWritten = true + } + + val row = ts + sep + payload.values.joinToString(sep) + newLine + + writer.write(row.toByteArray()) + } catch (exception: JsonSyntaxException) { + Log.e("db", exception.toString()) + } + } + } + } + + truncate() + + } catch (e: Exception) { + e.printStackTrace() + } + + cursor.close() + } + + private fun truncate() { + dbWrite.execSQL("DELETE FROM ${LogEntry.TABLE_NAME}"); + dbWrite.execSQL("VACUUM"); + } + + fun close() { + dbHelper.close() + } + + } +} + + + + + diff --git a/app/src/main/java/com/example/sensortestingapp/DecoderIaq.kt b/app/src/main/java/com/example/sensortestingapp/DecoderIaq.kt index 04ae385..269e21e 100644 --- a/app/src/main/java/com/example/sensortestingapp/DecoderIaq.kt +++ b/app/src/main/java/com/example/sensortestingapp/DecoderIaq.kt @@ -109,5 +109,9 @@ object DecoderIaq { ", occ=" + occupancy + '}' } + + fun toCsv() : String { + return "${msgType},${co2},${voc},${humidity},${temperature},${pressure}${pm25},${pm10},${occupancy}" + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt b/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt index d2780f0..7676af0 100644 --- a/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt +++ b/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt @@ -37,8 +37,10 @@ class KirbyDevice( private val context: Context, private val connectionManager: ConnectionManager, private val bleDevice: BluetoothDevice, + private val loggerDb: LoggerContract.LoggerDb, private val onStateChange: (device: KirbyDevice) -> Unit, + ) : BleListener(bleDevice.address), DeviceListEntry { private val queue: RequestQueue = Volley.newRequestQueue(context) @@ -138,6 +140,8 @@ class KirbyDevice( Log.i("BleListener", "Char received: $payload") val base64Payload = Base64.getEncoder().encodeToString(characteristic.value) publishMeasurement(base64Payload) + + loggerDb.writeLog( measurement) } measurements.add(payload) diff --git a/app/src/main/java/com/example/sensortestingapp/MainActivity.kt b/app/src/main/java/com/example/sensortestingapp/MainActivity.kt index 7649c94..a220afd 100644 --- a/app/src/main/java/com/example/sensortestingapp/MainActivity.kt +++ b/app/src/main/java/com/example/sensortestingapp/MainActivity.kt @@ -12,6 +12,7 @@ import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log @@ -19,16 +20,23 @@ import android.view.Menu import android.view.MenuItem import android.view.View.INVISIBLE import android.view.View.VISIBLE +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.recyclerview.widget.SimpleItemAnimator import com.example.sensortestingapp.databinding.ActivityMainBinding +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1 private const val RUNTIME_PERMISSION_REQUEST_CODE = 2 + fun Context.hasPermission(permissionType: String): Boolean { return ContextCompat.checkSelfPermission(this, permissionType) == PackageManager.PERMISSION_GRANTED @@ -47,6 +55,9 @@ fun Context.hasRequiredRuntimePermissions(): Boolean { class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding + private lateinit var loggerDb: LoggerContract.LoggerDb + private lateinit var createFileLauncher: ActivityResultLauncher + private val bluetoothAdapter: BluetoothAdapter by lazy { val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager @@ -66,10 +77,12 @@ class MainActivity : AppCompatActivity() { private var onPermissionsGrantedCallback: Runnable? = null + @RequiresApi(Build.VERSION_CODES.R) override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) @@ -90,8 +103,19 @@ class MainActivity : AppCompatActivity() { } } + loggerDb = LoggerContract.LoggerDb(this.applicationContext) + setupDevicesList() + checkStoragePermission() + + createFileLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri: Uri? -> + uri?.let { + // Use the URI to write your CSV content + loggerDb.exportToUri(uri) + } + } } private fun toggleScanning(): Unit { @@ -107,6 +131,21 @@ class MainActivity : AppCompatActivity() { return deviceName.contains("kirby") || deviceName.contains("krby") } + private fun checkStoragePermission() { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + // Permission is not granted, request it + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 4242 + ) + } + } + private fun setupDevicesList() { binding.devicesList.apply { @@ -181,7 +220,7 @@ class MainActivity : AppCompatActivity() { private fun newKirbyDevice(bleDevice: BluetoothDevice): KirbyDevice { - val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice) { + val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb) { val i = kirbyDevices.indexOfFirst { d -> d === it } runOnUiThread { deviceListAdapter.notifyItemChanged(i) @@ -201,12 +240,20 @@ class MainActivity : AppCompatActivity() { return true } + @RequiresApi(Build.VERSION_CODES.R) override fun onOptionsItemSelected(item: MenuItem): Boolean { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. return when (item.itemId) { - R.id.action_settings -> true + R.id.action_export -> { + + val date = LocalDateTime.now() + val fileName = "kirby_export_${date.format(DateTimeFormatter.ISO_DATE_TIME)}.csv" + createFileLauncher.launch(fileName) + return true + } + else -> super.onOptionsItemSelected(item) } } @@ -218,6 +265,11 @@ class MainActivity : AppCompatActivity() { } } + override fun onDestroy() { + loggerDb.close() + super.onDestroy() + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { @@ -275,10 +327,14 @@ class MainActivity : AppCompatActivity() { } } + private fun Activity.requestRelevantRuntimePermissions() { + if (hasRequiredRuntimePermissions()) { return } + + when { Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> { requestLocationPermission() @@ -290,6 +346,7 @@ class MainActivity : AppCompatActivity() { } } + private fun requestLocationPermission() { val onClick = { dialog: DialogInterface, which: Int -> ActivityCompat.requestPermissions( @@ -318,6 +375,7 @@ class MainActivity : AppCompatActivity() { } } + private fun requestBluetoothPermissions() { val onClick = { dialog: DialogInterface, which: Int -> ActivityCompat.requestPermissions( diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index b9692d9..7dcf834 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -3,8 +3,8 @@ xmlns:tools="http://schemas.android.com/tools" tools:context="com.example.sensortestingapp.MainActivity"> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d1c990..73f63fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ Sensor Testing App - Settings + Export to csv First Fragment Second Fragment diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..deaf64f --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,10 @@ + + + + + +