feat: store and export measurements locally

This commit is contained in:
Stefan Zollinger
2024-04-10 10:07:46 +02:00
parent d02311f058
commit fd76cd8ff5
9 changed files with 266 additions and 5 deletions

View File

@@ -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<HashMap<String, Any>>() {}.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<String, Any> = 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()
}
}
}

View File

@@ -109,5 +109,9 @@ object DecoderIaq {
", occ=" + occupancy +
'}'
}
fun toCsv() : String {
return "${msgType},${co2},${voc},${humidity},${temperature},${pressure}${pm25},${pm10},${occupancy}"
}
}
}

View File

@@ -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)

View File

@@ -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<String>
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(