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

@@ -58,6 +58,8 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'androidx.recyclerview:recyclerview:1.2.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 group: 'commons-codec', name: 'commons-codec', version: '1.16.0'
implementation "com.android.volley:volley:1.2.1" implementation "com.android.volley:volley:1.2.1"

View File

@@ -17,6 +17,17 @@
android:name="android.permission.ACCESS_FINE_LOCATION" android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission <uses-permission
android:name="android.permission.FOREGROUND_SERVICE" /> android:name="android.permission.FOREGROUND_SERVICE" />
@@ -50,6 +61,17 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".BLEService" android:foregroundServiceType="connectedDevice" /> <service android:name=".BLEService" android:foregroundServiceType="connectedDevice" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

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 + ", 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 context: Context,
private val connectionManager: ConnectionManager, private val connectionManager: ConnectionManager,
private val bleDevice: BluetoothDevice, private val bleDevice: BluetoothDevice,
private val loggerDb: LoggerContract.LoggerDb,
private val onStateChange: (device: KirbyDevice) -> Unit, private val onStateChange: (device: KirbyDevice) -> Unit,
) : BleListener(bleDevice.address), DeviceListEntry { ) : BleListener(bleDevice.address), DeviceListEntry {
private val queue: RequestQueue = Volley.newRequestQueue(context) private val queue: RequestQueue = Volley.newRequestQueue(context)
@@ -138,6 +140,8 @@ class KirbyDevice(
Log.i("BleListener", "Char received: $payload") Log.i("BleListener", "Char received: $payload")
val base64Payload = Base64.getEncoder().encodeToString(characteristic.value) val base64Payload = Base64.getEncoder().encodeToString(characteristic.value)
publishMeasurement(base64Payload) publishMeasurement(base64Payload)
loggerDb.writeLog( measurement)
} }
measurements.add(payload) measurements.add(payload)

View File

@@ -12,6 +12,7 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@@ -19,16 +20,23 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE 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.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import com.example.sensortestingapp.databinding.ActivityMainBinding 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 ENABLE_BLUETOOTH_REQUEST_CODE = 1
private const val RUNTIME_PERMISSION_REQUEST_CODE = 2 private const val RUNTIME_PERMISSION_REQUEST_CODE = 2
fun Context.hasPermission(permissionType: String): Boolean { fun Context.hasPermission(permissionType: String): Boolean {
return ContextCompat.checkSelfPermission(this, permissionType) == return ContextCompat.checkSelfPermission(this, permissionType) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
@@ -47,6 +55,9 @@ fun Context.hasRequiredRuntimePermissions(): Boolean {
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var loggerDb: LoggerContract.LoggerDb
private lateinit var createFileLauncher: ActivityResultLauncher<String>
private val bluetoothAdapter: BluetoothAdapter by lazy { private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@@ -66,10 +77,12 @@ class MainActivity : AppCompatActivity() {
private var onPermissionsGrantedCallback: Runnable? = null private var onPermissionsGrantedCallback: Runnable? = null
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -90,8 +103,19 @@ class MainActivity : AppCompatActivity() {
} }
} }
loggerDb = LoggerContract.LoggerDb(this.applicationContext)
setupDevicesList() 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 { private fun toggleScanning(): Unit {
@@ -107,6 +131,21 @@ class MainActivity : AppCompatActivity() {
return deviceName.contains("kirby") || deviceName.contains("krby") 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() { private fun setupDevicesList() {
binding.devicesList.apply { binding.devicesList.apply {
@@ -181,7 +220,7 @@ class MainActivity : AppCompatActivity() {
private fun newKirbyDevice(bleDevice: BluetoothDevice): KirbyDevice { 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 } val i = kirbyDevices.indexOfFirst { d -> d === it }
runOnUiThread { runOnUiThread {
deviceListAdapter.notifyItemChanged(i) deviceListAdapter.notifyItemChanged(i)
@@ -201,12 +240,20 @@ class MainActivity : AppCompatActivity() {
return true return true
} }
@RequiresApi(Build.VERSION_CODES.R)
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Handle action bar item clicks here. The action bar will // Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long // automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml. // as you specify a parent activity in AndroidManifest.xml.
return when (item.itemId) { 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) 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?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
when (requestCode) { when (requestCode) {
@@ -275,10 +327,14 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun Activity.requestRelevantRuntimePermissions() { private fun Activity.requestRelevantRuntimePermissions() {
if (hasRequiredRuntimePermissions()) { if (hasRequiredRuntimePermissions()) {
return return
} }
when { when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> { Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
requestLocationPermission() requestLocationPermission()
@@ -290,6 +346,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun requestLocationPermission() { private fun requestLocationPermission() {
val onClick = { dialog: DialogInterface, which: Int -> val onClick = { dialog: DialogInterface, which: Int ->
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
@@ -318,6 +375,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun requestBluetoothPermissions() { private fun requestBluetoothPermissions() {
val onClick = { dialog: DialogInterface, which: Int -> val onClick = { dialog: DialogInterface, which: Int ->
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(

View File

@@ -3,8 +3,8 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:context="com.example.sensortestingapp.MainActivity"> tools:context="com.example.sensortestingapp.MainActivity">
<item <item
android:id="@+id/action_settings" android:id="@+id/action_export"
android:orderInCategory="100" android:orderInCategory="100"
android:title="@string/action_settings" android:title="@string/action_export"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

View File

@@ -1,6 +1,6 @@
<resources> <resources>
<string name="app_name">Sensor Testing App</string> <string name="app_name">Sensor Testing App</string>
<string name="action_settings">Settings</string> <string name="action_export">Export to csv</string>
<!-- Strings used for fragments for navigation --> <!-- Strings used for fragments for navigation -->
<string name="first_fragment_label">First Fragment</string> <string name="first_fragment_label">First Fragment</string>
<string name="second_fragment_label">Second Fragment</string> <string name="second_fragment_label">Second Fragment</string>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="files"
path="."/>
<external-path
name="external_files"
path="."/>
</paths>