feat: store and export measurements locally
This commit is contained in:
161
app/src/main/java/com/example/sensortestingapp/Database.kt
Normal file
161
app/src/main/java/com/example/sensortestingapp/Database.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -109,5 +109,9 @@ object DecoderIaq {
|
||||
", occ=" + occupancy +
|
||||
'}'
|
||||
}
|
||||
|
||||
fun toCsv() : String {
|
||||
return "${msgType},${co2},${voc},${humidity},${temperature},${pressure}${pm25},${pm10},${occupancy}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user