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