feat: store and export measurements locally
This commit is contained in:
@@ -17,6 +17,17 @@
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
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
|
||||
android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
@@ -50,6 +61,17 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<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>
|
||||
|
||||
</manifest>
|
||||
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(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context="com.example.sensortestingapp.MainActivity">
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:id="@+id/action_export"
|
||||
android:orderInCategory="100"
|
||||
android:title="@string/action_settings"
|
||||
android:title="@string/action_export"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<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 -->
|
||||
<string name="first_fragment_label">First Fragment</string>
|
||||
<string name="second_fragment_label">Second Fragment</string>
|
||||
|
||||
10
app/src/main/res/xml/provider_paths.xml
Normal file
10
app/src/main/res/xml/provider_paths.xml
Normal 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>
|
||||
Reference in New Issue
Block a user