diff --git a/.gitignore b/.gitignore
index f002a331..f2bb714a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,10 @@
.gradle
/build
/app/build
+/app/benchmark/build
+
+# Kotlin K2
+/.kotlin
# Android Studio
# Needed for configuration only
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5c7c5607..2a673026 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,23 @@
+/*
+ * Copyright (c) 2024 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+import com.google.android.libraries.mapsplatform.secrets_gradle_plugin.loadPropertiesFile
+
/*
* Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
*
@@ -18,29 +38,31 @@
plugins {
alias(libs.plugins.android.application)
+ alias(libs.plugins.androidx.room)
alias(libs.plugins.jetbrains.kotlin.android)
- alias(libs.plugins.jetbrains.kotlin.kapt)
alias(libs.plugins.jetbrains.kotlin.parcelize)
alias(libs.plugins.jetbrains.kotlin.serialization)
+// alias(libs.plugins.jetbrains.kotlin.jvm)
alias(libs.plugins.google.gms.services)
alias(libs.plugins.google.secrets)
alias(libs.plugins.firebase.crashlytics)
alias(libs.plugins.hilt)
alias(libs.plugins.junit5)
alias(libs.plugins.ksp)
+ alias(libs.plugins.compose.compiler)
}
android {
- compileSdk = 34
+ compileSdk = 35
namespace = "illyan.jay"
defaultConfig {
applicationId = "illyan.jay"
- minSdk = 21
- targetSdk = 34
- versionCode = 17
- versionName = "0.3.6-alpha"
+ minSdk = 23
+ targetSdk = 35
+ versionCode = 18
+ versionName = "0.4.0-alpha"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -68,10 +90,30 @@ android {
"proguard-rules.pro"
)
}
- create("benchmark") {
- initWith(getByName("release"))
- signingConfig = signingConfigs.getByName("debug")
- matchingFallbacks += listOf("release")
+ }
+
+ signingConfigs {
+ val properties = loadPropertiesFile("../local.properties").toMap()
+
+ val debugStorePath = properties["DEBUG_KEY_PATH"].toString()
+ val debugKeyAlias = properties["DEBUG_KEY_ALIAS"].toString()
+ val debugStorePassword = properties["DEBUG_KEYSTORE_PASSWORD"].toString()
+ val debugKeyPassword = properties["DEBUG_KEY_PASSWORD"].toString()
+ getByName("debug") {
+ storeFile = file(debugStorePath)
+ keyAlias = debugKeyAlias
+ storePassword = debugStorePassword
+ keyPassword = debugKeyPassword
+ }
+ val releaseStorePath = properties["RELEASE_KEY_PATH"].toString()
+ val releaseKeyAlias = properties["RELEASE_KEY_ALIAS"].toString()
+ val releaseStorePassword = properties["RELEASE_KEYSTORE_PASSWORD"].toString()
+ val releaseKeyPassword = properties["RELEASE_KEY_PASSWORD"].toString()
+ create("release") {
+ storeFile = file(releaseStorePath)
+ keyAlias = releaseKeyAlias
+ storePassword = releaseStorePassword
+ keyPassword = releaseKeyPassword
}
}
@@ -90,10 +132,6 @@ android {
buildConfig = true
}
- composeOptions {
- kotlinCompilerExtensionVersion = libs.versions.compose.compiler.orNull
- }
-
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -158,6 +196,9 @@ dependencies {
// Item Swipe
implementation(libs.saket.swipe)
+ // Math for interpolation
+ implementation(libs.apache.commons.math3)
+
// Mapbox
implementation(libs.mapbox.maps)
implementation(libs.mapbox.search)
@@ -170,7 +211,7 @@ dependencies {
// Hilt
implementation(libs.hilt)
- kapt(libs.hilt.compiler)
+ ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Timber
@@ -212,12 +253,12 @@ dependencies {
// Firebase
implementation(platform(libs.firebase.bom))
- implementation(libs.firebase.auth.ktx)
- implementation(libs.firebase.config.ktx)
- implementation(libs.firebase.analytics.ktx)
- implementation(libs.firebase.crashlytics.ktx)
- implementation(libs.firebase.firestore.ktx)
- implementation(libs.firebase.perf.ktx)
+ implementation(libs.firebase.auth)
+ implementation(libs.firebase.config)
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
+ implementation(libs.firebase.firestore)
+ implementation(libs.firebase.perf)
// Firebase ML
implementation(libs.firebase.ml.modeldownloader)
@@ -243,10 +284,10 @@ dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
}
-kapt {
- correctErrorTypes = true
-}
-
hilt {
enableAggregatingTask = true
}
+
+room {
+ schemaDirectory("$projectDir/schemas")
+}
diff --git a/app/schemas/illyan.jay.data.room.JayDatabase/35.json b/app/schemas/illyan.jay.data.room.JayDatabase/35.json
new file mode 100644
index 00000000..025edd54
--- /dev/null
+++ b/app/schemas/illyan.jay.data.room.JayDatabase/35.json
@@ -0,0 +1,370 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 35,
+ "identityHash": "1f55028a3f681523d23c94c62ab57223",
+ "entities": [
+ {
+ "tableName": "sessions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `startDateTime` INTEGER NOT NULL, `endDateTime` INTEGER, `startLocationLatitude` REAL, `startLocationLongitude` REAL, `endLocationLatitude` REAL, `endLocationLongitude` REAL, `startLocationName` TEXT, `endLocationName` TEXT, `distance` REAL, `ownerUUID` TEXT, `clientUUID` TEXT, PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startDateTime",
+ "columnName": "startDateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endDateTime",
+ "columnName": "endDateTime",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "startLocationLatitude",
+ "columnName": "startLocationLatitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "startLocationLongitude",
+ "columnName": "startLocationLongitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLocationLatitude",
+ "columnName": "endLocationLatitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLocationLongitude",
+ "columnName": "endLocationLongitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "startLocationName",
+ "columnName": "startLocationName",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLocationName",
+ "columnName": "endLocationName",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "distance",
+ "columnName": "distance",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ownerUUID",
+ "columnName": "ownerUUID",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "clientUUID",
+ "columnName": "clientUUID",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sessions_uuid",
+ "unique": false,
+ "columnNames": [
+ "uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_uuid` ON `${TABLE_NAME}` (`uuid`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "locations",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionUUID` TEXT NOT NULL, `time` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `accuracy` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedAccuracy` REAL NOT NULL, `bearing` INTEGER NOT NULL, `bearingAccuracy` INTEGER NOT NULL, `altitude` INTEGER NOT NULL, `verticalAccuracy` INTEGER NOT NULL, PRIMARY KEY(`sessionUUID`, `time`), FOREIGN KEY(`sessionUUID`) REFERENCES `sessions`(`uuid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+ "fields": [
+ {
+ "fieldPath": "sessionUUID",
+ "columnName": "sessionUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "time",
+ "columnName": "time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accuracy",
+ "columnName": "accuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speed",
+ "columnName": "speed",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speedAccuracy",
+ "columnName": "speedAccuracy",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bearing",
+ "columnName": "bearing",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bearingAccuracy",
+ "columnName": "bearingAccuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "altitude",
+ "columnName": "altitude",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "verticalAccuracy",
+ "columnName": "verticalAccuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionUUID",
+ "time"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_locations_sessionUUID",
+ "unique": false,
+ "columnNames": [
+ "sessionUUID"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_sessionUUID` ON `${TABLE_NAME}` (`sessionUUID`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "sessions",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionUUID"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "sensor_events",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionUUID` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `accuracy` INTEGER NOT NULL, `x` REAL NOT NULL, `y` REAL NOT NULL, `z` REAL NOT NULL, PRIMARY KEY(`sessionUUID`, `time`, `type`), FOREIGN KEY(`sessionUUID`) REFERENCES `sessions`(`uuid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+ "fields": [
+ {
+ "fieldPath": "sessionUUID",
+ "columnName": "sessionUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "time",
+ "columnName": "time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accuracy",
+ "columnName": "accuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "x",
+ "columnName": "x",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "y",
+ "columnName": "y",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "z",
+ "columnName": "z",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionUUID",
+ "time",
+ "type"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sensor_events_sessionUUID",
+ "unique": false,
+ "columnNames": [
+ "sessionUUID"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sensor_events_sessionUUID` ON `${TABLE_NAME}` (`sessionUUID`)"
+ },
+ {
+ "name": "index_sensor_events_time",
+ "unique": false,
+ "columnNames": [
+ "time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sensor_events_time` ON `${TABLE_NAME}` (`time`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "sessions",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionUUID"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "preferences",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userUUID` TEXT NOT NULL, `freeDriveAutoStart` INTEGER NOT NULL, `analyticsEnabled` INTEGER NOT NULL, `showAds` INTEGER NOT NULL, `theme` TEXT NOT NULL, `dynamicColorEnabled` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, `lastUpdateToAnalytics` INTEGER, `shouldSync` INTEGER NOT NULL, PRIMARY KEY(`userUUID`))",
+ "fields": [
+ {
+ "fieldPath": "userUUID",
+ "columnName": "userUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "freeDriveAutoStart",
+ "columnName": "freeDriveAutoStart",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "analyticsEnabled",
+ "columnName": "analyticsEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "showAds",
+ "columnName": "showAds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "theme",
+ "columnName": "theme",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dynamicColorEnabled",
+ "columnName": "dynamicColorEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "lastUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdateToAnalytics",
+ "columnName": "lastUpdateToAnalytics",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "shouldSync",
+ "columnName": "shouldSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userUUID"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f55028a3f681523d23c94c62ab57223')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/illyan.jay.data.room.JayDatabase/36.json b/app/schemas/illyan.jay.data.room.JayDatabase/36.json
new file mode 100644
index 00000000..9d0d6d26
--- /dev/null
+++ b/app/schemas/illyan.jay.data.room.JayDatabase/36.json
@@ -0,0 +1,425 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 36,
+ "identityHash": "03cde5d693539b025d0bb8aca2d2f905",
+ "entities": [
+ {
+ "tableName": "sessions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `startDateTime` INTEGER NOT NULL, `endDateTime` INTEGER, `startLocationLatitude` REAL, `startLocationLongitude` REAL, `endLocationLatitude` REAL, `endLocationLongitude` REAL, `startLocationName` TEXT, `endLocationName` TEXT, `distance` REAL, `ownerUUID` TEXT, `clientUUID` TEXT, PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startDateTime",
+ "columnName": "startDateTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endDateTime",
+ "columnName": "endDateTime",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "startLocationLatitude",
+ "columnName": "startLocationLatitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "startLocationLongitude",
+ "columnName": "startLocationLongitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLocationLatitude",
+ "columnName": "endLocationLatitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLocationLongitude",
+ "columnName": "endLocationLongitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "startLocationName",
+ "columnName": "startLocationName",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "endLocationName",
+ "columnName": "endLocationName",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "distance",
+ "columnName": "distance",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ownerUUID",
+ "columnName": "ownerUUID",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "clientUUID",
+ "columnName": "clientUUID",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sessions_uuid",
+ "unique": false,
+ "columnNames": [
+ "uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_uuid` ON `${TABLE_NAME}` (`uuid`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "locations",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionUUID` TEXT NOT NULL, `time` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `accuracy` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedAccuracy` REAL NOT NULL, `bearing` INTEGER NOT NULL, `bearingAccuracy` INTEGER NOT NULL, `altitude` INTEGER NOT NULL, `verticalAccuracy` INTEGER NOT NULL, PRIMARY KEY(`sessionUUID`, `time`), FOREIGN KEY(`sessionUUID`) REFERENCES `sessions`(`uuid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+ "fields": [
+ {
+ "fieldPath": "sessionUUID",
+ "columnName": "sessionUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "time",
+ "columnName": "time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accuracy",
+ "columnName": "accuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speed",
+ "columnName": "speed",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speedAccuracy",
+ "columnName": "speedAccuracy",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bearing",
+ "columnName": "bearing",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bearingAccuracy",
+ "columnName": "bearingAccuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "altitude",
+ "columnName": "altitude",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "verticalAccuracy",
+ "columnName": "verticalAccuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionUUID",
+ "time"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_locations_sessionUUID",
+ "unique": false,
+ "columnNames": [
+ "sessionUUID"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_sessionUUID` ON `${TABLE_NAME}` (`sessionUUID`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "sessions",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionUUID"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "sensor_events",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionUUID` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` INTEGER NOT NULL, `accuracy` INTEGER NOT NULL, `x` REAL NOT NULL, `y` REAL NOT NULL, `z` REAL NOT NULL, PRIMARY KEY(`sessionUUID`, `time`, `type`), FOREIGN KEY(`sessionUUID`) REFERENCES `sessions`(`uuid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+ "fields": [
+ {
+ "fieldPath": "sessionUUID",
+ "columnName": "sessionUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "time",
+ "columnName": "time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accuracy",
+ "columnName": "accuracy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "x",
+ "columnName": "x",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "y",
+ "columnName": "y",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "z",
+ "columnName": "z",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionUUID",
+ "time",
+ "type"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sensor_events_sessionUUID",
+ "unique": false,
+ "columnNames": [
+ "sessionUUID"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sensor_events_sessionUUID` ON `${TABLE_NAME}` (`sessionUUID`)"
+ },
+ {
+ "name": "index_sensor_events_time",
+ "unique": false,
+ "columnNames": [
+ "time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sensor_events_time` ON `${TABLE_NAME}` (`time`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "sessions",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionUUID"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "preferences",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userUUID` TEXT NOT NULL, `freeDriveAutoStart` INTEGER NOT NULL, `analyticsEnabled` INTEGER NOT NULL, `showAds` INTEGER NOT NULL, `theme` TEXT NOT NULL, `dynamicColorEnabled` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, `lastUpdateToAnalytics` INTEGER, `shouldSync` INTEGER NOT NULL, PRIMARY KEY(`userUUID`))",
+ "fields": [
+ {
+ "fieldPath": "userUUID",
+ "columnName": "userUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "freeDriveAutoStart",
+ "columnName": "freeDriveAutoStart",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "analyticsEnabled",
+ "columnName": "analyticsEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "showAds",
+ "columnName": "showAds",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "theme",
+ "columnName": "theme",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dynamicColorEnabled",
+ "columnName": "dynamicColorEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "lastUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdateToAnalytics",
+ "columnName": "lastUpdateToAnalytics",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "shouldSync",
+ "columnName": "shouldSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userUUID"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "aggressions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionUUID` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `aggression` REAL NOT NULL, PRIMARY KEY(`sessionUUID`, `timestamp`), FOREIGN KEY(`sessionUUID`) REFERENCES `sessions`(`uuid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+ "fields": [
+ {
+ "fieldPath": "sessionUUID",
+ "columnName": "sessionUUID",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "aggression",
+ "columnName": "aggression",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionUUID",
+ "timestamp"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_aggressions_sessionUUID",
+ "unique": false,
+ "columnNames": [
+ "sessionUUID"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_aggressions_sessionUUID` ON `${TABLE_NAME}` (`sessionUUID`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "sessions",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionUUID"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '03cde5d693539b025d0bb8aca2d2f905')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ad541fa4..5db5cba0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,6 @@
+
+
diff --git a/app/src/main/java/illyan/jay/data/firebaseml/datasource/FirebaseMLDataSource.kt b/app/src/main/java/illyan/jay/data/firebaseml/datasource/FirebaseMLDataSource.kt
new file mode 100644
index 00000000..8d80bef2
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/firebaseml/datasource/FirebaseMLDataSource.kt
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.firebaseml.datasource
+
+import com.google.firebase.ml.modeldownloader.CustomModel
+import com.google.firebase.ml.modeldownloader.CustomModelDownloadConditions
+import com.google.firebase.ml.modeldownloader.DownloadType
+import com.google.firebase.ml.modeldownloader.FirebaseModelDownloader
+import com.google.firebase.remoteconfig.ConfigUpdate
+import com.google.firebase.remoteconfig.ConfigUpdateListener
+import com.google.firebase.remoteconfig.FirebaseRemoteConfig
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigException
+import illyan.jay.di.CoroutineScopeIO
+import illyan.jay.util.FirebaseRemoteConfigKeys
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class FirebaseMLDataSource @Inject constructor(
+ private val modelDownloader: FirebaseModelDownloader,
+ private val remoteConfig: FirebaseRemoteConfig,
+ @CoroutineScopeIO private val coroutineScopeIO: CoroutineScope
+) {
+ private val _downloadingModels = MutableStateFlow(mutableListOf())
+ val downloadingModels = _downloadingModels.asStateFlow()
+
+ private val _availableModels = MutableStateFlow(getAvailableModelNames())
+ val availableModels = _availableModels.asStateFlow()
+
+ private val _downloadedModels = MutableStateFlow(listOf())
+ val downloadedModels = _downloadedModels.asStateFlow()
+
+ init {
+ refreshDownloadedModelList()
+ }
+
+ private fun refreshDownloadedModelList() {
+ modelDownloader.listDownloadedModels()
+ .addOnSuccessListener { models ->
+ _downloadedModels.update { models.toList() }
+ }
+ }
+
+ // TODO: Add restrictions based on authenticated user model access.
+ // In other words, only allow the user to download the models they have access to.
+ fun getModel(
+ modelName: String,
+ conditions: CustomModelDownloadConditions = CustomModelDownloadConditions.Builder().build(),
+ downloadType: DownloadType = DownloadType.LATEST_MODEL,
+ ): SharedFlow {
+ Timber.v("Downloading model: $modelName")
+ val flow = MutableSharedFlow(extraBufferCapacity = 1)
+ modelDownloader
+ .getModel(modelName, downloadType, conditions)
+ .addOnSuccessListener { model ->
+ _downloadingModels.update {
+ if (model.file != null) {
+ Timber.d("Model download in progress: $modelName")
+ startedDownloadingModel(modelName)
+ } else {
+ Timber.d("Model download ended: $modelName")
+ finishedDownloadingModel(modelName)
+ }
+ it
+ }
+ coroutineScopeIO.launch { flow.emit(model) }
+ refreshDownloadedModelList()
+ }
+ .addOnFailureListener {
+ finishedDownloadingModel(modelName)
+ coroutineScopeIO.launch { flow.emit(null) }
+ }
+ .addOnCanceledListener {
+ finishedDownloadingModel(modelName)
+ coroutineScopeIO.launch { flow.emit(null) }
+ }
+ return flow
+ }
+
+ private fun startedDownloadingModel(modelName: String) {
+ _downloadingModels.update { models ->
+ models.add(modelName)
+ models.distinct().toMutableList()
+ }
+ }
+ private fun finishedDownloadingModel(modelName: String) {
+ _downloadingModels.update { models ->
+ models.filter { it != modelName }
+ models
+ }
+ }
+
+ fun getModelId(
+ modelName: String,
+ conditions: CustomModelDownloadConditions = CustomModelDownloadConditions.Builder().build(),
+ downloadType: DownloadType = DownloadType.LATEST_MODEL,
+ ): SharedFlow {
+ val flow = MutableSharedFlow(extraBufferCapacity = 1)
+ modelDownloader
+ .getModelDownloadId(
+ modelName,
+ modelDownloader.getModel(modelName, downloadType, conditions)
+ )
+ .addOnSuccessListener {
+ Timber.v("Model ID: $it")
+ coroutineScopeIO.launch { flow.emit(it) }
+ }
+ .addOnFailureListener {
+ Timber.e(it, "Model ID query failed: ${it.message}")
+ coroutineScopeIO.launch { flow.emit(null) }
+ }
+ .addOnCanceledListener {
+ Timber.v("Model ID query cancelled.")
+ coroutineScopeIO.launch { flow.emit(null) }
+ }
+ return flow
+ }
+
+ // TODO: restrict access to only the models the user has access to.
+ fun getDownloadedModels(): SharedFlow> {
+ val flow = MutableSharedFlow>(extraBufferCapacity = 1)
+ modelDownloader.listDownloadedModels()
+ .addOnSuccessListener {
+ coroutineScopeIO.launch { flow.emit(it.toList()) }
+ }
+ return flow
+ }
+
+ private fun getAvailableModelNames() = Json.decodeFromString>(
+ remoteConfig.getString(FirebaseRemoteConfigKeys.MLAvailableModels)
+ )
+
+ fun refreshAvailableModelsList() {
+ Timber.v("Refreshing available models list from Firebase Remote Config.")
+ remoteConfig.addOnConfigUpdateListener(
+ object : ConfigUpdateListener {
+ override fun onUpdate(configUpdate: ConfigUpdate) {
+ remoteConfig.activate()
+ _availableModels.update { getAvailableModelNames() }
+ Timber.v("Available models list updated. Updating Remote Config.")
+ }
+
+ override fun onError(error: FirebaseRemoteConfigException) {
+ Timber.e(error, "Error updating available models list: ${error.message}")
+ _availableModels.update {
+ emptyList()
+ }
+ }
+ }
+ )
+ remoteConfig.fetchAndActivate()
+ }
+
+ suspend fun deleteAllModels() {
+ Timber.v("Deleting all downloaded models.")
+ downloadedModels.first { models ->
+ models.forEach { deleteModel(it.name) }
+ true
+ }
+// modelDownloader
+// .listDownloadedModels()
+// .addOnSuccessListener { models ->
+// models.forEach { model ->
+// modelDownloader.deleteDownloadedModel(model.name)
+// }
+// refreshDownloadedModelList()
+// }
+// .addOnFailureListener {
+// Timber.e(it, "Error deleting downloaded models: ${it.message}")
+// }
+// .addOnCanceledListener { Timber.v("Deleting downloaded models cancelled.") }
+ }
+
+ fun deleteModel(modelName: String) {
+ Timber.v("Deleting model: $modelName")
+ modelDownloader.deleteDownloadedModel(modelName)
+ .addOnSuccessListener { refreshDownloadedModelList() }
+ .addOnFailureListener { Timber.e(it, "Error deleting downloaded model: ${it.message}") }
+ .addOnCanceledListener { Timber.v("Deleting downloaded model cancelled.") }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/data/firestore/Mapping.kt b/app/src/main/java/illyan/jay/data/firestore/Mapping.kt
index 1ace1363..321c6d40 100644
--- a/app/src/main/java/illyan/jay/data/firestore/Mapping.kt
+++ b/app/src/main/java/illyan/jay/data/firestore/Mapping.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -27,15 +27,18 @@ import illyan.jay.BuildConfig
import illyan.jay.data.DataStatus
import illyan.jay.data.firestore.model.FirestoreLocation
import illyan.jay.data.firestore.model.FirestorePath
+import illyan.jay.data.firestore.model.FirestoreSensorEvent
+import illyan.jay.data.firestore.model.FirestoreSensorEvents
import illyan.jay.data.firestore.model.FirestoreSession
import illyan.jay.data.firestore.model.FirestoreUser
import illyan.jay.data.firestore.model.FirestoreUserPreferences
import illyan.jay.data.firestore.serializers.TimestampSerializer
+import illyan.jay.domain.model.DomainAggression
import illyan.jay.domain.model.DomainLocation
import illyan.jay.domain.model.DomainPreferences
+import illyan.jay.domain.model.DomainSensorEvent
import illyan.jay.domain.model.DomainSession
import illyan.jay.util.toGeoPoint
-import illyan.jay.util.toInstant
import illyan.jay.util.toTimestamp
import illyan.jay.util.toZonedDateTime
import kotlinx.parcelize.Parcelize
@@ -108,6 +111,7 @@ fun DomainPreferences.toFirestoreModel() = FirestoreUserPreferences(
lastUpdateToAnalytics = lastUpdateToAnalytics?.toTimestamp(),
)
+@JvmName("locationsToFirestorePath")
fun List.toPath(
sessionUUID: String,
ownerUUID: String
@@ -126,7 +130,6 @@ fun List.toPath(
verticalAccuracy = it.verticalAccuracy.toInt()
)
}
-
val locationsBlob = Blob.fromBytes(Zstd.compress(ProtoBuf.encodeToByteArray(pathLocations)))
val path = FirestorePath(
uuid = UUID.nameUUIDFromBytes(locationsBlob.toBytes()).toString(),
@@ -134,12 +137,83 @@ fun List.toPath(
ownerUUID = ownerUUID,
locations = locationsBlob
)
+ if (BuildConfig.DEBUG) { testCompressions(this) }
+ return path
+}
+
+@JvmName("aggressionsToFirestorePath")
+fun List.toPath(
+ sessionUUID: String,
+ ownerUUID: String
+): FirestorePath {
+ val aggressionsBlob = Blob.fromBytes(Zstd.compress(ProtoBuf.encodeToByteArray(this)))
+ return FirestorePath(
+ uuid = "", // This should be an incomplete path, so no UUID (UUID is based only on locations)
+ sessionUUID = sessionUUID,
+ ownerUUID = ownerUUID,
+ locations = Blob.fromBytes(ByteArray(0)),
+ aggressions = aggressionsBlob
+ )
+}
+
+fun List.toFirebaseSensorEvents(
+ sessionUUID: String,
+ ownerUUID: String
+): FirestoreSensorEvents {
+ val events = map {
+ FirestoreSensorEvent(
+ timestamp = it.zonedDateTime.toTimestamp(),
+ accuracy = it.accuracy.toInt(),
+ type = it.type.toInt(),
+ x = it.x,
+ y = it.y,
+ z = it.z
+ )
+ }
+
+ val eventsBlob = Blob.fromBytes(Zstd.compress(ProtoBuf.encodeToByteArray(events)))
if (BuildConfig.DEBUG) {
- testCompressions(this)
+ testCompressions(
+ rawData = this,
+ compressors = listOf(
+ { array: ByteArray ->
+ Zstd.compress(array, Zstd.maxCompressionLevel())
+ } to "Zstd"
+ ),
+ translators = listOf { data ->
+ ProtoBuf.encodeToByteArray(
+ data.map {
+ FirestoreSensorEvent(
+ timestamp = it.zonedDateTime.toTimestamp(),
+ accuracy = it.accuracy.toInt(),
+ type = it.type.toInt(),
+ x = it.x,
+ y = it.y,
+ z = it.z
+ )
+ }
+ ) to "Default Sensor Events"
+ },
+ dataSizeHeuristic = { data ->
+ val startMilli = data
+ .minBy { it.zonedDateTime.toInstant().toEpochMilli() }
+ .zonedDateTime.toInstant().toEpochMilli()
+ val endMilli = data
+ .maxBy { it.zonedDateTime.toInstant().toEpochMilli() }
+ .zonedDateTime.toInstant().toEpochMilli()
+ val durationInMinutes = (endMilli - startMilli).milliseconds.inWholeMinutes
+ "$durationInMinutes minutes of sensor data"
+ }
+ )
}
- return path
+ return FirestoreSensorEvents(
+ uuid = UUID.nameUUIDFromBytes(eventsBlob.toBytes()).toString(),
+ sessionUUID = sessionUUID,
+ ownerUUID = ownerUUID,
+ events = eventsBlob
+ )
}
private fun testCompressions(domainLocations: List) {
@@ -173,96 +247,116 @@ private fun testCompressions(domainLocations: List) {
// This growth then accumulates with more data,
// so it's actually around 25% growth in large datasets.
- val optimizedLocations = domainLocations.map {
- LocationWithoutSessionIdOptimized(
- zonedDateTime = it.zonedDateTime.toTimestamp(),
- latitude = it.latitude,
- longitude = it.longitude,
- speed = it.speed,
- accuracy = it.accuracy,
- bearing = it.bearing,
- bearingAccuracy = it.bearingAccuracy,
- altitude = it.altitude,
- speedAccuracy = it.speedAccuracy,
- verticalAccuracy = it.verticalAccuracy
- )
- }
-
- val locations = domainLocations.map {
- LocationWithoutSessionId(
- zonedDateTime = it.zonedDateTime.toTimestamp(),
- latitude = it.latitude,
- longitude = it.longitude,
- speed = it.speed,
- accuracy = it.accuracy.toInt(),
- bearing = it.bearing.toInt(),
- bearingAccuracy = it.bearingAccuracy.toInt(),
- altitude = it.altitude.toInt(),
- speedAccuracy = it.speedAccuracy,
- verticalAccuracy = it.verticalAccuracy.toInt()
- )
- }
-
- val unoptimizedLocations = domainLocations.map {
- LocationWithoutSessionIdUnoptimized(
- zonedDateTime = it.zonedDateTime.toTimestamp(),
- latitude = it.latitude.toDouble(),
- longitude = it.longitude.toDouble(),
- speed = it.speed.toDouble(),
- accuracy = it.accuracy.toLong(),
- bearing = it.bearing.toLong(),
- bearingAccuracy = it.bearingAccuracy.toLong(),
- altitude = it.altitude.toLong(),
- speedAccuracy = it.speedAccuracy.toDouble(),
- verticalAccuracy = it.verticalAccuracy.toLong()
- )
- }
-
- val data = listOf(
- ProtoBuf.encodeToByteArray(optimizedLocations) to "Optimized Locations",
- ProtoBuf.encodeToByteArray(locations) to "Default Locations",
- ProtoBuf.encodeToByteArray(unoptimizedLocations) to "Unoptimized Locations"
+ testCompressions(
+ rawData = domainLocations,
+ translators = listOf(
+ { data ->
+ ProtoBuf.encodeToByteArray(
+ data.map {
+ LocationWithoutSessionIdOptimized(
+ zonedDateTime = it.zonedDateTime.toTimestamp(),
+ latitude = it.latitude,
+ longitude = it.longitude,
+ speed = it.speed,
+ accuracy = it.accuracy,
+ bearing = it.bearing,
+ bearingAccuracy = it.bearingAccuracy,
+ altitude = it.altitude,
+ speedAccuracy = it.speedAccuracy,
+ verticalAccuracy = it.verticalAccuracy
+ )
+ }
+ ) to "Optimized Locations"
+ },
+ { data ->
+ ProtoBuf.encodeToByteArray(
+ data.map {
+ LocationWithoutSessionId(
+ zonedDateTime = it.zonedDateTime.toTimestamp(),
+ latitude = it.latitude,
+ longitude = it.longitude,
+ speed = it.speed,
+ accuracy = it.accuracy.toInt(),
+ bearing = it.bearing.toInt(),
+ bearingAccuracy = it.bearingAccuracy.toInt(),
+ altitude = it.altitude.toInt(),
+ speedAccuracy = it.speedAccuracy,
+ verticalAccuracy = it.verticalAccuracy.toInt()
+ )
+ }
+ ) to "Default Locations"
+ },
+ { data ->
+ ProtoBuf.encodeToByteArray(
+ data.map {
+ LocationWithoutSessionIdUnoptimized(
+ zonedDateTime = it.zonedDateTime.toTimestamp(),
+ latitude = it.latitude.toDouble(),
+ longitude = it.longitude.toDouble(),
+ speed = it.speed.toDouble(),
+ accuracy = it.accuracy.toLong(),
+ bearing = it.bearing.toLong(),
+ bearingAccuracy = it.bearingAccuracy.toLong(),
+ altitude = it.altitude.toLong(),
+ speedAccuracy = it.speedAccuracy.toDouble(),
+ verticalAccuracy = it.verticalAccuracy.toLong()
+ )
+ }
+ ) to "Unoptimized Locations"
+ }
+ ),
+ compressors = listOf(
+ { array: ByteArray ->
+ val locationsParcel = Parcel.obtain()
+ locationsParcel.writeByteArray(array)
+ val bytes = locationsParcel.marshall()
+ locationsParcel.recycle()
+ bytes
+ } to "Parcel marshall",
+ ::encodeWithGZIP to "GZIP",
+ { array: ByteArray ->
+ val zlibByteArrayOutputStream = ByteArrayOutputStream()
+ val deflater = Deflater(Deflater.BEST_COMPRESSION)
+ val deflaterOutputStream = DeflaterOutputStream(zlibByteArrayOutputStream, deflater)
+ deflaterOutputStream.write(array)
+ val compressedBytes = zlibByteArrayOutputStream.toByteArray()
+ deflaterOutputStream.close()
+ zlibByteArrayOutputStream.close()
+ compressedBytes
+ } to "ZLIB",
+ { array: ByteArray ->
+ Zstd.compress(array, Zstd.maxCompressionLevel())
+ } to "Zstd"
+ ),
+ dataSizeHeuristic = { locations ->
+ val startMilli = locations
+ .minBy { it.zonedDateTime.toInstant().toEpochMilli() }
+ .zonedDateTime.toInstant().toEpochMilli()
+ val endMilli = locations
+ .maxBy { it.zonedDateTime.toInstant().toEpochMilli() }
+ .zonedDateTime.toInstant().toEpochMilli()
+ val durationInMinutes = (endMilli - startMilli).milliseconds.inWholeMinutes
+ "$durationInMinutes minutes of location data"
+ }
)
+}
- val compressions = listOf ByteArray, String>>(
- { array: ByteArray ->
- val locationsParcel = Parcel.obtain()
- locationsParcel.writeByteArray(array)
- val bytes = locationsParcel.marshall()
- locationsParcel.recycle()
- bytes
- } to "Parcel marshall",
- ::encodeWithGZIP to "GZIP",
- { array: ByteArray ->
- val zlibByteArrayOutputStream = ByteArrayOutputStream()
- val deflater = Deflater(Deflater.BEST_COMPRESSION)
- val deflaterOutputStream = DeflaterOutputStream(zlibByteArrayOutputStream, deflater)
- deflaterOutputStream.write(array)
- val compressedBytes = zlibByteArrayOutputStream.toByteArray()
- deflaterOutputStream.close()
- zlibByteArrayOutputStream.close()
- compressedBytes
- } to "ZLIB",
- { array: ByteArray ->
- Zstd.compress(array, Zstd.maxCompressionLevel())
- } to "Zstd"
- )
+private fun testCompressions(
+ rawData: T,
+ translators: List<(T) -> Pair>, // Translated data and its name
+ compressors: List ByteArray, String>>, // Compressors and their name
+ dataSizeHeuristic: (T) -> String, // Heuristic to determine data size
+) {
+ val data = translators.map { it(rawData) }
val stringBuilder = StringBuilder()
- val startMilli = locations
- .minBy { it.zonedDateTime.toInstant().toEpochMilli() }
- .zonedDateTime.toInstant().toEpochMilli()
- val endMilli = locations
- .maxBy { it.zonedDateTime.toInstant().toEpochMilli() }
- .zonedDateTime.toInstant().toEpochMilli()
- val durationInMinutes = (endMilli - startMilli).milliseconds.inWholeMinutes
- stringBuilder.append("Compressing $durationInMinutes minutes of Location data\n")
+ stringBuilder.append("Compressing ${dataSizeHeuristic(rawData)}\n")
data.forEach {
val byteArray = it.first
stringBuilder.append("Data type: ${it.second}\n")
- compressions.forEach { algo ->
+ compressors.forEach { algo ->
val compressedBytes = algo.first(byteArray)
val algoName = algo.second
stringBuilder.append("$algoName: ${compressedBytes.size} bytes\n")
@@ -336,11 +430,11 @@ data class LocationWithoutSessionIdUnoptimized(
var verticalAccuracy: Long = Long.MIN_VALUE, // in meters
) : Parcelable
-fun List.toPaths(
+fun List.toChunkedFirebaseSensorEvents(
sessionUUID: String,
ownerUUID: String,
- thresholdInMinutes: Int = 300
-): List {
+ thresholdInMinutes: Int = 5
+): List {
if (isEmpty()) return emptyList()
val startMilli = minOf { it.zonedDateTime.toInstant().toEpochMilli() }
val groupedByTime = groupBy {
@@ -350,7 +444,7 @@ fun List.toPaths(
return groupedByTime.map { groups ->
groups.value
.sortedBy { it.zonedDateTime.toInstant().toEpochMilli() }
- .toPath(sessionUUID, ownerUUID)
+ .toFirebaseSensorEvents(sessionUUID, ownerUUID)
}
}
@@ -359,6 +453,7 @@ fun List.toDomainLocations(): List {
val domainLocations = mutableListOf()
forEach { path ->
+ if (path.locations.toBytes().isEmpty()) return@forEach
val locations = ProtoBuf.decodeFromByteArray>(Zstd.decompress(path.locations.toBytes(), 1_000_000))
domainLocations.addAll(locations.map { it.toDomainModel(path.sessionUUID) })
}
@@ -366,6 +461,32 @@ fun List.toDomainLocations(): List {
return domainLocations.sortedBy { it.zonedDateTime.toInstant().toEpochMilli() }
}
+@OptIn(ExperimentalSerializationApi::class)
+fun List.toDomainAggressions(): List? {
+ if (all { it.aggressions?.toBytes() == null }) return null // No aggressions at all
+ val domainAggressions = mutableListOf()
+
+ forEach { path ->
+ if (path.aggressions?.toBytes() == null || path.aggressions.toBytes().isEmpty()) return@forEach
+ val aggressions = ProtoBuf.decodeFromByteArray>(Zstd.decompress(path.aggressions.toBytes(), 1_000_000))
+ domainAggressions.addAll(aggressions)
+ }
+
+ return domainAggressions.sortedBy { it.timestamp }
+}
+
+fun List.toDomainSensorEvents(): List {
+ val domainSensorEvents = mutableListOf()
+
+ forEach { events ->
+ if (events.events.toBytes().isEmpty()) return@forEach
+ val sensorEvents = ProtoBuf.decodeFromByteArray>(Zstd.decompress(events.events.toBytes(), 1_000_000))
+ domainSensorEvents.addAll(sensorEvents.map { it.toDomainModel(events.sessionUUID) })
+ }
+
+ return domainSensorEvents.sortedBy { it.zonedDateTime.toInstant().toEpochMilli() }
+}
+
fun FirestoreLocation.toDomainModel(
sessionUUID: String
) = DomainLocation(
@@ -382,6 +503,18 @@ fun FirestoreLocation.toDomainModel(
verticalAccuracy = verticalAccuracy.toShort()
)
+fun FirestoreSensorEvent.toDomainModel(
+ sessionUUID: String
+) = DomainSensorEvent(
+ zonedDateTime = timestamp.toZonedDateTime(),
+ sessionUUID = sessionUUID,
+ accuracy = accuracy.toByte(),
+ type = type.toByte(),
+ x = x,
+ y = y,
+ z = z
+)
+
fun DataStatus.toDomainPreferencesStatus(): DataStatus {
return DataStatus(
data = data?.run { preferences?.toDomainModel(uuid) },
diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt
index 77a23f76..74670f5b 100644
--- a/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2023-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -27,10 +27,12 @@ import com.google.firebase.firestore.ktx.snapshots
import com.google.firebase.firestore.ktx.toObjects
import com.google.maps.android.ktx.utils.sphericalPathLength
import illyan.jay.data.firestore.model.FirestorePath
+import illyan.jay.data.firestore.toDomainAggressions
import illyan.jay.data.firestore.toDomainLocations
-import illyan.jay.data.firestore.toPaths
+import illyan.jay.data.firestore.toPath
import illyan.jay.di.CoroutineScopeIO
import illyan.jay.domain.interactor.AuthInteractor
+import illyan.jay.domain.model.DomainAggression
import illyan.jay.domain.model.DomainLocation
import illyan.jay.domain.model.DomainSession
import illyan.jay.util.awaitOperations
@@ -41,6 +43,7 @@ import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.time.Duration.Companion.minutes
@Singleton
class PathFirestoreDataSource @Inject constructor(
@@ -58,6 +61,7 @@ class PathFirestoreDataSource @Inject constructor(
.document(data.uuid)
}
+ // FIXME: remove or use
private val locationsByUser = lazy { userPathsDataFlowBuilder().data }
fun getLocationsBySession(sessionUUID: String) =
@@ -81,15 +85,37 @@ class PathFirestoreDataSource @Inject constructor(
)
) {}.data
+ fun getAggressionsBySession(sessionUUID: String) =
+ object : FirestoreDataFlow, List>(
+ firestore = firestore,
+ coroutineScopeIO = coroutineScopeIO,
+ toDomainModel = { it?.toDomainAggressions() },
+ appLifecycle = appLifecycle,
+ snapshotHandler = FirestoreQuerySnapshotHandler(
+ snapshotToObject = { it.toObjects() },
+ snapshotSourceFlow = authInteractor.userUUIDStateFlow.map { uuid ->
+ if (uuid != null) {
+ firestore
+ .collection(FirestorePath.CollectionName)
+ .whereEqualTo(FirestorePath.FieldSessionUUID, sessionUUID)
+ .snapshots(MetadataChanges.INCLUDE)
+ } else {
+ null
+ }
+ }
+ )
+ ) {}.data
+
fun insertLocations(
domainSessions: List,
domainLocations: List,
+ domainAggressions: List,
onFailure: (Exception) -> Unit = { Timber.e(it, "Error while inserting locations for ${domainSessions.size} sessions: ${it.message}") },
onCancel: () -> Unit = { Timber.i("Operation canceled") },
onSuccess: () -> Unit = { Timber.d("Successfully inserted locations for ${domainSessions.size} sessions") }
) {
setData(
- data = getPathsFromSessions(domainSessions, domainLocations),
+ data = getPathsFromSessions(domainSessions, domainLocations, domainAggressions),
onFailure = onFailure,
onCancel = onCancel,
onSuccess = onSuccess,
@@ -99,10 +125,11 @@ class PathFirestoreDataSource @Inject constructor(
fun insertLocations(
domainSessions: List,
domainLocations: List,
+ domainAggressions: List,
batch: WriteBatch,
) {
setData(
- data = getPathsFromSessions(domainSessions, domainLocations),
+ data = getPathsFromSessions(domainSessions, domainLocations, domainAggressions),
batch = batch,
)
}
@@ -110,16 +137,36 @@ class PathFirestoreDataSource @Inject constructor(
private fun getPathsFromSessions(
domainSessions: List,
domainLocations: List,
+ domainAggressions: List
): List {
val paths = mutableListOf()
domainSessions.forEach { session ->
val locationsForThisSession = domainLocations.filter { it.sessionUUID.contentEquals(session.uuid) }
+ val aggressionsForThisSession = domainAggressions.filter { it.sessionUUID.contentEquals(session.uuid) }
if (session.distance == null) {
session.distance = locationsForThisSession
.sortedBy { it.zonedDateTime.toInstant().toEpochMilli() }
.map { it.latLng }.sphericalPathLength().toFloat()
}
- paths.addAll(locationsForThisSession.toPaths(session.uuid, session.ownerUUID!!))
+ val thresholdInMinutes = 300
+ if (locationsForThisSession.isEmpty()) return emptyList()
+ val startMilli = locationsForThisSession.minOf { it.zonedDateTime.toInstant().toEpochMilli() }
+ val groupedByTime = locationsForThisSession.groupBy {
+ (it.zonedDateTime.toInstant().toEpochMilli() - startMilli) / thresholdInMinutes.minutes.inWholeMilliseconds
+ }
+ paths.addAll(groupedByTime.map { groups ->
+ groups.value
+ .sortedBy { it.zonedDateTime.toInstant().toEpochMilli() }
+ .toPath(session.uuid, session.ownerUUID!!)
+ })
+ val aggressionsGroupedByTime = aggressionsForThisSession.groupBy {
+ (it.timestamp - startMilli) / thresholdInMinutes.minutes.inWholeMilliseconds
+ }.map { it.value }
+ aggressionsGroupedByTime.forEachIndexed { index, aggressions ->
+ if (index < paths.size) {
+ paths[index] = paths[index].copy(aggressions = aggressions.toPath(session.uuid, session.ownerUUID!!).aggressions)
+ }
+ }
}
return paths
}
diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt
new file mode 100644
index 00000000..dd9456f7
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.firestore.datasource
+
+import androidx.lifecycle.Lifecycle
+import com.google.firebase.firestore.DocumentReference
+import com.google.firebase.firestore.FirebaseFirestore
+import com.google.firebase.firestore.MetadataChanges
+import com.google.firebase.firestore.WriteBatch
+import com.google.firebase.firestore.ktx.snapshots
+import com.google.firebase.firestore.ktx.toObjects
+import illyan.jay.data.firestore.model.FirestoreSensorEvents
+import illyan.jay.data.firestore.toChunkedFirebaseSensorEvents
+import illyan.jay.data.firestore.toDomainSensorEvents
+import illyan.jay.di.CoroutineScopeIO
+import illyan.jay.domain.interactor.AuthInteractor
+import illyan.jay.domain.model.DomainSensorEvent
+import illyan.jay.domain.model.DomainSession
+import illyan.jay.util.awaitOperations
+import illyan.jay.util.delete
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+class SensorEventsFirestoreDataSource @Inject constructor(
+ private val firestore: FirebaseFirestore,
+ private val authInteractor: AuthInteractor,
+ private val appLifecycle: Lifecycle,
+ @CoroutineScopeIO private val coroutineScopeIO: CoroutineScope,
+// @UserSensorEventsSnapshotHandler private val userSensorEventsDataFlowBuilder: () -> UserPathsFirestoreDataFlow, // FIXME: remove or use
+) : FirestoreDataSource(
+ firestore = firestore
+) {
+ override fun getReference(data: FirestoreSensorEvents): DocumentReference {
+ return firestore
+ .collection(FirestoreSensorEvents.CollectionName)
+ .document(data.uuid)
+ }
+
+ fun getEventsBySession(sessionUUID: String) =
+ object : FirestoreDataFlow, List>(
+ firestore = firestore,
+ coroutineScopeIO = coroutineScopeIO,
+ toDomainModel = { it?.toDomainSensorEvents() },
+ appLifecycle = appLifecycle,
+ snapshotHandler = FirestoreQuerySnapshotHandler(
+ snapshotToObject = { it.toObjects() },
+ snapshotSourceFlow = authInteractor.userUUIDStateFlow.map { uuid ->
+ if (uuid != null) {
+ firestore
+ .collection(FirestoreSensorEvents.CollectionName)
+ .whereEqualTo(FirestoreSensorEvents.FieldSessionUUID, sessionUUID)
+ .snapshots(MetadataChanges.INCLUDE)
+ } else {
+ null
+ }
+ }
+ )
+ ) {}.data
+
+ private fun getEventsFromSessions(
+ domainSessions: List,
+ domainSensorEvents: List,
+ ): List {
+ val paths = mutableListOf()
+ domainSessions.forEach { session ->
+ val eventsForThisSession = domainSensorEvents.filter { it.sessionUUID.contentEquals(session.uuid) }
+ paths.addAll(eventsForThisSession.toChunkedFirebaseSensorEvents(session.uuid, session.ownerUUID!!))
+ }
+ return paths
+ }
+
+ fun insertEvents(
+ domainSessions: List,
+ domainSensorEvents: List,
+ batch: WriteBatch,
+ ) {
+ setData(
+ data = getEventsFromSessions(domainSessions, domainSensorEvents),
+ batch = batch,
+ )
+ }
+
+ suspend fun deleteSensorEventsForUser(
+ batch: WriteBatch,
+ userUUID: String = authInteractor.userUUID.toString(),
+ onWriteFinished: () -> Unit = {}
+ ) {
+ batch.delete(
+ query = firestore
+ .collection(FirestoreSensorEvents.CollectionName)
+ .whereEqualTo(FirestoreSensorEvents.FieldOwnerUUID, userUUID),
+ onOperationFinished = onWriteFinished
+ )
+ }
+
+ suspend fun deleteSensorEventsForSessions(
+ batch: WriteBatch,
+ sessionUUIDs: List,
+ onWriteFinished: () -> Unit = {}
+ ) {
+ // [Query.whereIn] can only take in at most 10 objects to compare
+ val chunkedUUIDs = sessionUUIDs.chunked(10)
+ awaitOperations(chunkedUUIDs.size) { onOperationFinished ->
+ chunkedUUIDs.forEach { chunk ->
+ batch.delete(
+ query = firestore
+ .collection(FirestoreSensorEvents.CollectionName)
+ .whereIn(FirestoreSensorEvents.FieldSessionUUID, chunk),
+ onOperationFinished = onOperationFinished
+ )
+ }
+ }
+ onWriteFinished()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/UserPathsFirestoreDataFlow.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/UserPathsFirestoreDataFlow.kt
index 79f71bf0..eaaa9ffb 100644
--- a/app/src/main/java/illyan/jay/data/firestore/datasource/UserPathsFirestoreDataFlow.kt
+++ b/app/src/main/java/illyan/jay/data/firestore/datasource/UserPathsFirestoreDataFlow.kt
@@ -23,6 +23,7 @@ import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.QuerySnapshot
import illyan.jay.data.firestore.model.FirestorePath
import illyan.jay.data.firestore.toDomainLocations
+import illyan.jay.di.CoroutineScopeIO
import illyan.jay.di.UserPathsSnapshotHandler
import illyan.jay.domain.model.DomainLocation
import kotlinx.coroutines.CoroutineScope
@@ -33,7 +34,7 @@ import javax.inject.Singleton
class UserPathsFirestoreDataFlow @Inject constructor(
firestore: FirebaseFirestore,
appLifecycle: Lifecycle,
- coroutineScopeIO: CoroutineScope,
+ @CoroutineScopeIO coroutineScopeIO: CoroutineScope,
@UserPathsSnapshotHandler snapshotHandler: FirestoreSnapshotHandler, QuerySnapshot>,
) : FirestoreDataFlow, List>>(
firestore = firestore,
diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/UserSensorEventsFirestoreDataFlow.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/UserSensorEventsFirestoreDataFlow.kt
new file mode 100644
index 00000000..3c5b73bf
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/firestore/datasource/UserSensorEventsFirestoreDataFlow.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.firestore.datasource
+
+import androidx.lifecycle.Lifecycle
+import com.google.firebase.firestore.FirebaseFirestore
+import com.google.firebase.firestore.QuerySnapshot
+import illyan.jay.data.firestore.model.FirestoreSensorEvents
+import illyan.jay.data.firestore.toDomainSensorEvents
+import illyan.jay.di.UserPathsSnapshotHandler
+import illyan.jay.domain.model.DomainSensorEvent
+import kotlinx.coroutines.CoroutineScope
+import javax.inject.Inject
+
+class UserSensorEventsFirestoreDataFlow @Inject constructor(
+ firestore: FirebaseFirestore,
+ appLifecycle: Lifecycle,
+ coroutineScopeIO: CoroutineScope,
+ @UserPathsSnapshotHandler snapshotHandler: FirestoreSnapshotHandler, QuerySnapshot>,
+) : FirestoreDataFlow, List>>(
+ firestore = firestore,
+ appLifecycle = appLifecycle,
+ coroutineScopeIO = coroutineScopeIO,
+ toDomainModel = { events ->
+ events
+ ?.groupBy { it.sessionUUID }
+ ?.map { it.value.toDomainSensorEvents() }
+ },
+ snapshotHandler = snapshotHandler,
+)
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/data/firestore/model/FirestorePath.kt b/app/src/main/java/illyan/jay/data/firestore/model/FirestorePath.kt
index 41edf4aa..3b5d0d53 100644
--- a/app/src/main/java/illyan/jay/data/firestore/model/FirestorePath.kt
+++ b/app/src/main/java/illyan/jay/data/firestore/model/FirestorePath.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -27,13 +27,15 @@ data class FirestorePath(
val uuid: String = "",
@PropertyName(FieldSessionUUID) val sessionUUID: String = "", // reference of the session this path is part of
@PropertyName(FieldOwnerUUID) val ownerUUID: String = "",
- @PropertyName(FieldLocations) val locations: Blob = Blob.fromBytes(ByteArray(0))
+ @PropertyName(FieldLocations) val locations: Blob = Blob.fromBytes(ByteArray(0)),
+ @PropertyName(FieldAggressions) val aggressions: Blob? = null
) {
companion object {
const val CollectionName = "paths"
const val FieldSessionUUID = "sessionUUID"
const val FieldOwnerUUID = "ownerUUID"
const val FieldLocations = "locations"
+ const val FieldAggressions = "aggressions"
}
}
diff --git a/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSensorEvent.kt b/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSensorEvent.kt
new file mode 100644
index 00000000..16317025
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSensorEvent.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.firestore.model
+
+import android.os.Parcelable
+import com.google.firebase.Timestamp
+import illyan.jay.data.firestore.serializers.TimestampSerializer
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+
+@Serializable
+@Parcelize
+data class FirestoreSensorEvent(
+ @Serializable(with = TimestampSerializer::class)
+ val timestamp: Timestamp,
+ val type: Int,
+ val accuracy: Int, // enum
+ val x: Float,
+ val y: Float,
+ val z: Float,
+) : Parcelable
diff --git a/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSensorEvents.kt b/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSensorEvents.kt
new file mode 100644
index 00000000..5f027879
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSensorEvents.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.firestore.model
+
+import com.google.firebase.firestore.Blob
+import com.google.firebase.firestore.DocumentId
+import com.google.firebase.firestore.PropertyName
+
+data class FirestoreSensorEvents(
+ @DocumentId
+ val uuid: String = "",
+ @PropertyName(FieldSessionUUID) val sessionUUID: String = "",
+ @PropertyName(FieldOwnerUUID) val ownerUUID: String = "",
+ @PropertyName(FieldEvents) val events: Blob = Blob.fromBytes(ByteArray(0))
+) {
+ companion object {
+ const val CollectionName = "sensor"
+ const val FieldSessionUUID = "sessionUUID"
+ const val FieldOwnerUUID = "ownerUUID"
+ const val FieldEvents = "events"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/data/room/JayDatabase.kt b/app/src/main/java/illyan/jay/data/room/JayDatabase.kt
index 6de09128..19e85143 100644
--- a/app/src/main/java/illyan/jay/data/room/JayDatabase.kt
+++ b/app/src/main/java/illyan/jay/data/room/JayDatabase.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -18,12 +18,14 @@
package illyan.jay.data.room
+import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import illyan.jay.data.room.dao.LocationDao
import illyan.jay.data.room.dao.PreferencesDao
import illyan.jay.data.room.dao.SensorEventDao
import illyan.jay.data.room.dao.SessionDao
+import illyan.jay.data.room.model.RoomAggression
import illyan.jay.data.room.model.RoomLocation
import illyan.jay.data.room.model.RoomPreferences
import illyan.jay.data.room.model.RoomSensorEvent
@@ -41,9 +43,12 @@ import illyan.jay.data.room.model.RoomSession
RoomLocation::class,
RoomSensorEvent::class,
RoomPreferences::class,
+ RoomAggression::class
],
- version = 35,
- exportSchema = false
+ version = 36,
+ autoMigrations = [
+ AutoMigration (from = 35, to = 36)
+ ]
)
abstract class JayDatabase : RoomDatabase() {
/**
diff --git a/app/src/main/java/illyan/jay/data/room/Mapping.kt b/app/src/main/java/illyan/jay/data/room/Mapping.kt
index 63a8c28b..3a92c968 100644
--- a/app/src/main/java/illyan/jay/data/room/Mapping.kt
+++ b/app/src/main/java/illyan/jay/data/room/Mapping.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -22,10 +22,12 @@ import android.hardware.SensorEvent
import android.location.Location
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
+import illyan.jay.data.room.model.RoomAggression
import illyan.jay.data.room.model.RoomLocation
import illyan.jay.data.room.model.RoomPreferences
import illyan.jay.data.room.model.RoomSensorEvent
import illyan.jay.data.room.model.RoomSession
+import illyan.jay.domain.model.DomainAggression
import illyan.jay.domain.model.DomainLocation
import illyan.jay.domain.model.DomainPreferences
import illyan.jay.domain.model.DomainSensorEvent
@@ -178,3 +180,15 @@ fun DomainPreferences.toRoomModel(
lastUpdateToAnalytics = lastUpdateToAnalytics?.toInstant()?.toEpochMilli(),
shouldSync = shouldSync,
)
+
+fun RoomAggression.toDomainModel() = DomainAggression(
+ sessionUUID = sessionUUID,
+ timestamp = timestamp,
+ aggression = aggression
+)
+
+fun DomainAggression.toRoomModel() = RoomAggression(
+ sessionUUID = sessionUUID,
+ timestamp = timestamp,
+ aggression = aggression
+)
diff --git a/app/src/main/java/illyan/jay/data/room/dao/LocationDao.kt b/app/src/main/java/illyan/jay/data/room/dao/LocationDao.kt
index e4c575da..2aa97c16 100644
--- a/app/src/main/java/illyan/jay/data/room/dao/LocationDao.kt
+++ b/app/src/main/java/illyan/jay/data/room/dao/LocationDao.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -23,8 +23,8 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
-import androidx.room.Transaction
import androidx.room.Update
+import illyan.jay.data.room.model.RoomAggression
import illyan.jay.data.room.model.RoomLocation
import kotlinx.coroutines.flow.Flow
@@ -42,6 +42,12 @@ interface LocationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertLocations(locations: List)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun upsertAggression(aggression: RoomAggression): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun upsertAggressions(aggressions: List)
+
@Update
fun updateLocation(location: RoomLocation): Int
@@ -60,19 +66,27 @@ interface LocationDao {
@Query("DELETE FROM locations WHERE sessionUUID = :sessionUUID")
fun deleteLocations(sessionUUID: String)
- @Transaction
+ @Query("DELETE FROM aggressions")
+ fun deleteAggressions()
+
+ @Query("DELETE FROM aggressions WHERE sessionUUID = :sessionUUID")
+ fun deleteAggressions(sessionUUID: String)
+
@Query("SELECT * FROM locations WHERE sessionUUID = :sessionUUID")
fun getLocations(sessionUUID: String): Flow>
- @Transaction
@Query("SELECT * FROM locations WHERE sessionUUID IN(:sessionUUIDs)")
fun getLocations(sessionUUIDs: List): Flow>
- @Transaction
+ @Query("SELECT * FROM aggressions WHERE sessionUUID = :sessionUUID")
+ fun getAggressions(sessionUUID: String): Flow>
+
+ @Query("SELECT * FROM aggressions WHERE sessionUUID IN(:sessionUUIDs)")
+ fun getAggressions(sessionUUIDs: List): Flow>
+
@Query("SELECT * FROM locations ORDER BY time DESC LIMIT :limit")
fun getLatestLocations(limit: Long): Flow>
- @Transaction
@Query("SELECT * FROM locations WHERE sessionUUID = :sessionUUID ORDER BY time DESC LIMIT :limit")
fun getLatestLocations(sessionUUID: String, limit: Long): Flow>
}
diff --git a/app/src/main/java/illyan/jay/data/room/dao/SensorEventDao.kt b/app/src/main/java/illyan/jay/data/room/dao/SensorEventDao.kt
index 243650cf..ad30ba4f 100644
--- a/app/src/main/java/illyan/jay/data/room/dao/SensorEventDao.kt
+++ b/app/src/main/java/illyan/jay/data/room/dao/SensorEventDao.kt
@@ -61,4 +61,7 @@ interface SensorEventDao {
@Query("SELECT * FROM sensor_events WHERE sessionUUID = :sessionUUID")
fun getSensorEvents(sessionUUID: String): Flow>
+
+ @Query("SELECT * FROM sensor_events WHERE sessionUUID IN(:sessionUUIDs)")
+ fun getSensorEvents(sessionUUIDs: List): Flow>
}
diff --git a/app/src/main/java/illyan/jay/data/room/dao/SessionDao.kt b/app/src/main/java/illyan/jay/data/room/dao/SessionDao.kt
index 2fdba5bc..d03d8a83 100644
--- a/app/src/main/java/illyan/jay/data/room/dao/SessionDao.kt
+++ b/app/src/main/java/illyan/jay/data/room/dao/SessionDao.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -23,7 +23,6 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
-import androidx.room.Transaction
import androidx.room.Update
import illyan.jay.data.room.model.RoomSession
import kotlinx.coroutines.flow.Flow
@@ -54,99 +53,75 @@ interface SessionDao {
@Delete
fun deleteSessions(sessions: List)
- @Transaction
@Query("DELETE FROM sessions WHERE ownerUUID IS :ownerUUID OR ownerUUID IS NULL")
fun deleteSessions(ownerUUID: String? = null)
- @Transaction
@Query("DELETE FROM sessions WHERE ownerUUID IS NULL")
fun deleteNotOwnedSessions()
- @Transaction
@Query("DELETE FROM sessions WHERE ownerUUID IS :ownerUUID")
fun deleteSessionsByOwner(ownerUUID: String? = null)
- @Transaction
@Query("DELETE FROM sessions WHERE ownerUUID IS :ownerUUID AND endDateTime IS NOT NULL")
fun deleteStoppedSessionsByOwner(ownerUUID: String? = null)
- @Transaction
@Query("SELECT * FROM sessions WHERE ownerUUID IS :ownerUUID OR ownerUUID IS NULL")
fun getSessions(ownerUUID: String? = null): Flow>
- @Transaction
@Query("SELECT * FROM sessions WHERE uuid IN (:sessionUUIDs)")
fun getSessions(sessionUUIDs: List): Flow>
- @Transaction
@Query("SELECT uuid FROM sessions WHERE ownerUUID IS :ownerUUID OR ownerUUID IS NULL")
fun getSessionUUIDs(ownerUUID: String? = null): Flow>
- @Transaction
@Query("SELECT * FROM sessions WHERE uuid = :uuid AND (ownerUUID IS :ownerUUID OR ownerUUID IS NULL) LIMIT 1")
fun getSession(uuid: String, ownerUUID: String? = null): Flow
- @Transaction
@Query("SELECT * FROM sessions WHERE endDateTime IS NOT NULL AND ownerUUID IS :ownerUUID")
fun getStoppedSessions(ownerUUID: String? = null): Flow>
- @Transaction
@Query("SELECT * FROM sessions WHERE endDateTime IS NULL AND (ownerUUID IS :ownerUUID OR ownerUUID IS NULL) ORDER BY startDateTime DESC")
fun getOngoingSessions(ownerUUID: String? = null): Flow>
- @Transaction
@Query("SELECT uuid FROM sessions WHERE endDateTime IS NULL AND (ownerUUID IS :ownerUUID OR ownerUUID IS NULL) ORDER BY startDateTime DESC")
fun getOngoingSessionUUIDs(ownerUUID: String? = null): Flow>
- @Transaction
@Query("SELECT * FROM sessions WHERE ownerUUID IS NULL ORDER BY startDateTime DESC")
fun getAllNotOwnedSessions(): Flow>
- @Transaction
@Query("SELECT * FROM sessions WHERE ownerUUID IS :ownerUUID ORDER BY startDateTime DESC")
fun getSessionsByOwner(ownerUUID: String? = null): Flow>
- @Transaction
@Query("UPDATE sessions SET ownerUUID = :ownerUUID WHERE ownerUUID IS NULL")
fun ownAllNotOwnedSessions(ownerUUID: String): Int
- @Transaction
@Query("UPDATE sessions SET ownerUUID = :ownerUUID WHERE ownerUUID IS NULL AND uuid = :uuid")
fun ownNotOwnedSession(uuid: String, ownerUUID: String): Int
- @Transaction
@Query("UPDATE sessions SET ownerUUID = :ownerUUID WHERE uuid IN(:uuids)")
fun ownSessions(uuids: List, ownerUUID: String)
- @Transaction
@Query("UPDATE sessions SET ownerUUID = NULL WHERE uuid IN(:uuids)")
fun disownSessions(uuids: List)
- @Transaction
@Query("UPDATE sessions SET ownerUUID = NULL WHERE ownerUUID IS :ownerUUID")
fun disownSessions(ownerUUID: String)
- @Transaction
@Query("UPDATE sessions SET startLocationLatitude = :latitude, startLocationLongitude = :longitude WHERE uuid IS :sessionUUID")
fun saveStartLocationForSession(sessionUUID: String, latitude: Double, longitude: Double)
- @Transaction
@Query("UPDATE sessions SET endLocationLatitude = :latitude, endLocationLongitude = :longitude WHERE uuid IS :sessionUUID")
fun saveEndLocationForSession(sessionUUID: String, latitude: Double, longitude: Double)
- @Transaction
@Query("UPDATE sessions SET startLocationName = :name WHERE uuid IS :sessionUUID")
fun saveStartLocationNameForSession(sessionUUID: String, name: String)
- @Transaction
@Query("UPDATE sessions SET endLocationName = :name WHERE uuid IS :sessionUUID")
fun saveEndLocationNameForSession(sessionUUID: String, name: String)
- @Transaction
@Query("UPDATE sessions SET clientUUID = :clientUUID WHERE uuid IS :sessionUUID")
fun assignClientToSession(sessionUUID: String, clientUUID: String?)
- @Transaction
@Query("UPDATE sessions SET distance = :distance WHERE uuid IS :sessionUUID")
fun saveDistanceForSession(sessionUUID: String, distance: Float?)
}
diff --git a/app/src/main/java/illyan/jay/data/room/datasource/LocationRoomDataSource.kt b/app/src/main/java/illyan/jay/data/room/datasource/LocationRoomDataSource.kt
index f43e02c5..413ed97c 100644
--- a/app/src/main/java/illyan/jay/data/room/datasource/LocationRoomDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/room/datasource/LocationRoomDataSource.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -19,9 +19,11 @@
package illyan.jay.data.room.datasource
import illyan.jay.data.room.dao.LocationDao
+import illyan.jay.data.room.model.RoomAggression
import illyan.jay.data.room.model.RoomLocation
import illyan.jay.data.room.toDomainModel
import illyan.jay.data.room.toRoomModel
+import illyan.jay.domain.model.DomainAggression
import illyan.jay.domain.model.DomainLocation
import kotlinx.coroutines.flow.map
import timber.log.Timber
@@ -71,6 +73,12 @@ class LocationRoomDataSource @Inject constructor(
fun getLocations(sessionUUIDs: List) = locationDao.getLocations(sessionUUIDs)
.map { it.map(RoomLocation::toDomainModel) }
+ fun getAggressions(sessionUUID: String) = locationDao.getAggressions(sessionUUID)
+ .map { it.map(RoomAggression::toDomainModel) }
+
+ fun getAggressions(sessionUUIDs: List) = locationDao.getAggressions(sessionUUIDs)
+ .map { it.map(RoomAggression::toDomainModel) }
+
/**
* Save location's data to Room database.
* Should be linked to a session to be accessible later on.
@@ -93,4 +101,11 @@ class LocationRoomDataSource @Inject constructor(
}
fun deleteLocationForSession(sessionUUID: String) = locationDao.deleteLocations(sessionUUID)
+
+ fun deleteAggressionsForSession(sessionUUID: String) = locationDao.deleteAggressions(sessionUUID)
+
+ fun saveAggressionsForSession(sessionUUID: String, aggressions: List) {
+ Timber.i("Saving ${aggressions.size} aggressions for session $sessionUUID")
+ locationDao.upsertAggressions(aggressions.map { it.toRoomModel() })
+ }
}
diff --git a/app/src/main/java/illyan/jay/data/room/datasource/SensorEventRoomDataSource.kt b/app/src/main/java/illyan/jay/data/room/datasource/SensorEventRoomDataSource.kt
index 4091d486..c4023029 100644
--- a/app/src/main/java/illyan/jay/data/room/datasource/SensorEventRoomDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/room/datasource/SensorEventRoomDataSource.kt
@@ -60,6 +60,10 @@ class SensorEventRoomDataSource @Inject constructor(
sensorEventDao.getSensorEvents(sessionUUID)
.map { it.map(RoomSensorEvent::toDomainModel) }
+ fun getSensorEvents(sessionUUIDs: List) =
+ sensorEventDao.getSensorEvents(sessionUUIDs)
+ .map { it.map(RoomSensorEvent::toDomainModel) }
+
/**
* Save sensorEvent's data to Room database.
* Should be linked to a session to be accessible later on.
diff --git a/app/src/main/java/illyan/jay/data/room/model/RoomAggression.kt b/app/src/main/java/illyan/jay/data/room/model/RoomAggression.kt
new file mode 100644
index 00000000..cbb17b83
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/room/model/RoomAggression.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.room.model
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+
+@Entity(
+ tableName = "aggressions",
+ foreignKeys = [
+ ForeignKey(
+ entity = RoomSession::class,
+ parentColumns = ["uuid"],
+ childColumns = ["sessionUUID"]
+ )
+ ],
+ indices = [Index(value = ["sessionUUID"])],
+ primaryKeys = ["sessionUUID", "timestamp"]
+)
+data class RoomAggression(
+ val sessionUUID: String,
+ val timestamp: Long, // in millis
+ val aggression: Float
+)
diff --git a/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt b/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt
new file mode 100644
index 00000000..ebc3d0b4
--- /dev/null
+++ b/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2023-2024 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.data.sensor
+
+import androidx.compose.ui.util.lerp
+import illyan.jay.domain.model.AdvancedImuSensorData
+import illyan.jay.domain.model.DomainSensorEvent
+import timber.log.Timber
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+object SensorFusion {
+ fun fuseSensorsWithInterval(
+ interval: Duration = 10.milliseconds,
+ accRaw: List,
+ accSmooth: List,
+ dirX: List,
+ dirY: List,
+ dirZ: List,
+ angVel: List,
+ angAccel: List,
+ ): List {
+ val allTimestamps = (accRaw + accSmooth + dirX + dirY + dirZ + angVel + angAccel)
+ .map { it.zonedDateTime.toInstant().toEpochMilli() }
+ .distinct()
+ .sorted()
+
+ return if (allTimestamps.isEmpty()) {
+ Timber.d("No sensor data to fuse")
+ emptyList()
+ } else {
+ val minTimestamp = allTimestamps.min()
+ val maxTimestamp = allTimestamps.max()
+ val intervals = (minTimestamp until maxTimestamp step interval.inWholeMilliseconds).toList()
+ return fuseSensors(
+ accRaw = accRaw,
+ accSmooth = accSmooth,
+ dirX = dirX,
+ dirY = dirY,
+ dirZ = dirZ,
+ angVel = angVel,
+ angAccel = angAccel,
+ intervals = intervals
+ )
+ }
+ }
+
+ fun fuseSensors(
+ accRaw: List,
+ accSmooth: List,
+ dirX: List,
+ dirY: List,
+ dirZ: List,
+ angVel: List,
+ angAccel: List,
+ intervals: List = (accRaw + accSmooth + dirX + dirY + dirZ + angVel + angAccel)
+ .map { it.zonedDateTime.toInstant().toEpochMilli() }
+ .distinct()
+ .sorted()
+ ): List {
+ Timber.d("Fusing sensor data")
+ // Merge all timestamps
+
+ if (intervals.isEmpty()) Timber.d("No sensor data to fuse")
+
+ val interpolatedDirX = interpolateValues(dirX, intervals)
+ val interpolatedDirY = interpolateValues(dirY, intervals)
+ val interpolatedDirZ = interpolateValues(dirZ, intervals)
+ val interpolatedAccRaw = interpolateValues(accRaw, intervals)
+ val interpolatedAccSmooth = interpolateValues(accSmooth, intervals)
+ val interpolatedAngVel = interpolateValues(angVel, intervals)
+ val interpolatedAngAccel = interpolateValues(angAccel, intervals)
+
+ return intervals.mapIndexed { index, timestamp ->
+ if (index % 25 == 0) Timber.v("Fusing sensor data for timestamp $timestamp (${index + 1}/${intervals.size})")
+ // Interpolate values for each timestamp
+ AdvancedImuSensorData(
+ dirX = interpolatedDirX[index],
+ dirY = interpolatedDirY[index],
+ dirZ = interpolatedDirZ[index],
+ accRaw = interpolatedAccRaw[index],
+ accSmooth = interpolatedAccSmooth[index],
+ angVel = interpolatedAngVel[index],
+ angAccel = interpolatedAngAccel[index],
+ timestamp = timestamp
+ )
+ }
+ }
+
+ private fun interpolateValues(events: List, timestamps: List): List> {
+ // Linear-like interpolation
+ if (events.isEmpty()) return timestamps.map { Triple(0.0, 0.0, 0.0) }
+ val firstEvent = events.first()
+ val lastEvent = events.last()
+ return timestamps.map { timestamp ->
+ val beforeEvent = events.firstOrNull { it.zonedDateTime.toInstant().toEpochMilli() <= timestamp } ?: firstEvent
+ val afterEvent = events.firstOrNull { it.zonedDateTime.toInstant().toEpochMilli() >= timestamp } ?: lastEvent
+ if (beforeEvent == afterEvent) return@map Triple(beforeEvent.x.toDouble(), beforeEvent.y.toDouble(), beforeEvent.z.toDouble())
+ val fraction = (timestamp - beforeEvent.zonedDateTime.toInstant().toEpochMilli()).toFloat() /
+ (afterEvent.zonedDateTime.toInstant().toEpochMilli() - beforeEvent.zonedDateTime.toInstant().toEpochMilli()).toFloat()
+ val interpolatedEventX = lerp(
+ beforeEvent.x,
+ afterEvent.x,
+ fraction
+ ).toDouble()
+ val interpolatedEventY = lerp(
+ beforeEvent.y,
+ afterEvent.y,
+ fraction
+ ).toDouble()
+ val interpolatedEventZ = lerp(
+ beforeEvent.z,
+ afterEvent.z,
+ fraction
+ ).toDouble()
+ Triple(interpolatedEventX, interpolatedEventY, interpolatedEventZ)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/di/FirebaseModule.kt b/app/src/main/java/illyan/jay/di/FirebaseModule.kt
index 136983da..74e41e94 100644
--- a/app/src/main/java/illyan/jay/di/FirebaseModule.kt
+++ b/app/src/main/java/illyan/jay/di/FirebaseModule.kt
@@ -27,6 +27,7 @@ import com.google.firebase.firestore.FirebaseFirestoreSettings
import com.google.firebase.firestore.PersistentCacheSettings
import com.google.firebase.firestore.ktx.firestore
import com.google.firebase.ktx.Firebase
+import com.google.firebase.ml.modeldownloader.FirebaseModelDownloader
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.ktx.remoteConfig
import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
@@ -95,4 +96,8 @@ object FirebaseModule {
}
return remoteConfig
}
+
+ @Singleton
+ @Provides
+ fun provideFirebaseModelDownloader() = FirebaseModelDownloader.getInstance()
}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/di/FirestoreModule.kt b/app/src/main/java/illyan/jay/di/FirestoreModule.kt
index f0ed07fe..1c079b30 100644
--- a/app/src/main/java/illyan/jay/di/FirestoreModule.kt
+++ b/app/src/main/java/illyan/jay/di/FirestoreModule.kt
@@ -34,7 +34,9 @@ import illyan.jay.data.firestore.datasource.FirestoreDocumentSnapshotHandler
import illyan.jay.data.firestore.datasource.FirestoreQuerySnapshotHandler
import illyan.jay.data.firestore.datasource.FirestoreSnapshotHandler
import illyan.jay.data.firestore.datasource.UserPathsFirestoreDataFlow
+import illyan.jay.data.firestore.datasource.UserSensorEventsFirestoreDataFlow
import illyan.jay.data.firestore.model.FirestorePath
+import illyan.jay.data.firestore.model.FirestoreSensorEvents
import illyan.jay.data.firestore.model.FirestoreUser
import illyan.jay.domain.interactor.AuthInteractor
import kotlinx.coroutines.CoroutineScope
@@ -91,6 +93,27 @@ object FirestoreModule {
)
}
+ @Provides
+ @UserSensorEventsSnapshotHandler
+ fun provideFirestoreSensorEventsSnapshotHandler(
+ firestore: FirebaseFirestore,
+ authInteractor: AuthInteractor,
+ ): FirestoreSnapshotHandler, QuerySnapshot> {
+ return FirestoreQuerySnapshotHandler(
+ snapshotToObject = { it.toObjects() },
+ snapshotSourceFlow = authInteractor.userUUIDStateFlow.map { uuid ->
+ if (uuid != null) {
+ firestore
+ .collection(FirestoreSensorEvents.CollectionName)
+ .whereEqualTo(FirestoreSensorEvents.FieldOwnerUUID, uuid)
+ .snapshots(MetadataChanges.INCLUDE)
+ } else {
+ null
+ }
+ }
+ )
+ }
+
@Provides
fun provideUserPathsFirestoreDataFlow(
firestore: FirebaseFirestore,
@@ -108,4 +131,22 @@ object FirestoreModule {
)
)
}
+
+ @Provides
+ fun provideUserSensorEventsFirestoreDataFlow(
+ firestore: FirebaseFirestore,
+ authInteractor: AuthInteractor,
+ appLifecycle: Lifecycle,
+ @CoroutineScopeIO coroutineScopeIO: CoroutineScope
+ ): () -> UserSensorEventsFirestoreDataFlow = {
+ UserSensorEventsFirestoreDataFlow(
+ firestore = firestore,
+ appLifecycle = appLifecycle,
+ coroutineScopeIO = coroutineScopeIO,
+ snapshotHandler = provideFirestoreSensorEventsSnapshotHandler(
+ firestore = firestore,
+ authInteractor = authInteractor
+ )
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/di/FirestoreSnapshotHandlerQualifier.kt b/app/src/main/java/illyan/jay/di/FirestoreSnapshotHandlerQualifier.kt
index 26117562..2850ac62 100644
--- a/app/src/main/java/illyan/jay/di/FirestoreSnapshotHandlerQualifier.kt
+++ b/app/src/main/java/illyan/jay/di/FirestoreSnapshotHandlerQualifier.kt
@@ -27,3 +27,7 @@ annotation class UserSnapshotHandler
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class UserPathsSnapshotHandler
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class UserSensorEventsSnapshotHandler
diff --git a/app/src/main/java/illyan/jay/domain/interactor/LocationInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/LocationInteractor.kt
index 3b12a554..083cb734 100644
--- a/app/src/main/java/illyan/jay/domain/interactor/LocationInteractor.kt
+++ b/app/src/main/java/illyan/jay/domain/interactor/LocationInteractor.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ * Copyright (c) 2022-2024 Balázs Püspök-Kiss (Illyan)
*
* Jay is a driver behaviour analytics app.
*
@@ -23,6 +23,7 @@ import illyan.jay.data.firestore.datasource.SessionFirestoreDataSource
import illyan.jay.data.room.datasource.LocationRoomDataSource
import illyan.jay.di.CoroutineScopeIO
import illyan.jay.di.CoroutineScopeMain
+import illyan.jay.domain.model.DomainAggression
import illyan.jay.domain.model.DomainLocation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -93,6 +94,10 @@ class LocationInteractor @Inject constructor(
return locationRoomDataSource.getLocations(sessionUUIDs)
}
+ fun getAggressions(sessionUUID: String): Flow> {
+ Timber.d("Trying to load path for session with ID from disk: ${sessionUUID.take(4)}")
+ return locationRoomDataSource.getAggressions(sessionUUID)
+ }
val syncedPathCollectionJobs = hashMapOf()
@@ -160,6 +165,72 @@ class LocationInteractor @Inject constructor(
return syncedPaths.asStateFlow()
}
+ val syncedPathAggressionCollectionJobs = hashMapOf()
+
+ /**
+ * Get the path for a session, either it being in the cloud or in the local database.
+ * Cannot react to user/session/location state changes, if the location was found in
+ * the local database, gets uploaded, then gets deleted from the local database, the listener
+ * would still listen to the local database's data.
+ */
+ suspend fun getSyncedPathAggressions(sessionUUID: String): StateFlow?> {
+ Timber.i("Trying to load path aggressions for session with ID: $sessionUUID")
+ if (syncedPathAggressionCollectionJobs[sessionUUID] != null) {
+ Timber.v("Cancelling current data collection job regarding the path aggressions of $sessionUUID")
+ syncedPathAggressionCollectionJobs[sessionUUID]?.cancel(CancellationException("New data collection job requested"))
+ syncedPathAggressionCollectionJobs.remove(sessionUUID)
+ }
+ val syncedPathAggressions = MutableStateFlow?>(null)
+ syncedPathAggressionCollectionJobs[sessionUUID] = coroutineScopeMain.launch(start = CoroutineStart.LAZY) {
+ pathFirestoreDataSource.getAggressionsBySession(sessionUUID).collectLatest { remoteAggressions ->
+ coroutineScopeIO.launch {
+ Timber.v("Found aggressions for session, caching it on disk")
+ remoteAggressions?.let { saveAggressions(it) }
+ }
+ syncedPathAggressions.update { remoteAggressions }
+ }
+ }
+ val session = sessionInteractor.getSession(sessionUUID).first()
+ if (session != null) {
+ Timber.v("Found session on disk")
+ coroutineScopeIO.launch {
+ val aggressions = getAggressions(sessionUUID).first()
+ if (aggressions.isEmpty()) {
+ Timber.v("Not found path aggressions for session on disk, checking cloud")
+ if (!authInteractor.isUserSignedIn) {
+ Timber.i("Not authenticated to access cloud, return an empty list")
+ syncedPathAggressions.update { emptyList() }
+ } else {
+ syncedPathAggressionCollectionJobs[sessionUUID]?.start()
+ }
+ } else {
+ Timber.i("Found path aggressions on disk")
+ syncedPathAggressions.update { aggressions }
+ }
+ }
+ } else {
+ Timber.v("Not found session on disk, checking cloud")
+ if (!authInteractor.isUserSignedIn) {
+ Timber.i("Not authenticated to access cloud, return an empty list")
+ syncedPathAggressions.update { emptyList() }
+ } else {
+ coroutineScopeIO.launch {
+ sessionFirestoreDataSource.sessions.first { sessions ->
+ if (sessions != null && sessions.any { it.uuid == sessionUUID }) {
+ Timber.v("Found session in cloud, caching it on disk")
+ coroutineScopeIO.launch {
+ sessionInteractor.saveSession(sessions.first { it.uuid == sessionUUID })
+ syncedPathAggressionCollectionJobs[sessionUUID]?.start()
+ }
+ }
+ sessions != null
+ }
+ }
+ }
+ }
+ return syncedPathAggressions.asStateFlow()
+ }
+
/**
* Save location's data to Room database.
* Should be linked to a session to be accessible later on.
@@ -180,12 +251,19 @@ class LocationInteractor @Inject constructor(
locationRoomDataSource.saveLocations(locations)
}
+ fun saveAggressions(aggressions: List) {
+ if (aggressions.isEmpty()) {
+ Timber.v("No aggressions to save")
+ return
+ }
+ locationRoomDataSource.saveAggressionsForSession(aggressions.first().sessionUUID, aggressions)
+ }
+
fun isPathStoredForSession(sessionUUID: String): Flow {
Timber.d("Checking if a path is stored for session $sessionUUID")
return locationRoomDataSource.getLatestLocations(sessionUUID, 1).map { it.isNotEmpty() }
}
-
companion object {
const val LOCATION_REQUEST_INTERVAL_REALTIME = 200L
const val LOCATION_REQUEST_INTERVAL_FREQUENT = 500L
diff --git a/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt
new file mode 100644
index 00000000..e0a95147
--- /dev/null
+++ b/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2023-2024 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.domain.interactor
+
+import android.hardware.Sensor
+import android.os.Build
+import com.google.firebase.ml.modeldownloader.CustomModelDownloadConditions
+import com.google.firebase.ml.modeldownloader.DownloadType
+import illyan.jay.data.firebaseml.datasource.FirebaseMLDataSource
+import illyan.jay.data.sensor.SensorFusion
+import illyan.jay.di.CoroutineScopeIO
+import illyan.jay.util.toZonedDateTime
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import org.tensorflow.lite.Interpreter
+import timber.log.Timber
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.time.Instant
+import java.time.ZonedDateTime
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ModelInteractor @Inject constructor(
+ private val firebaseMLDataSource: FirebaseMLDataSource,
+ private val sensorEventInteractor: SensorEventInteractor,
+ private val locationInteractor: LocationInteractor,
+ @CoroutineScopeIO private val coroutineScopeIO: CoroutineScope,
+) {
+ val downloadingModels = firebaseMLDataSource.downloadingModels
+ val availableModels = firebaseMLDataSource.availableModels
+ val downloadedModels = firebaseMLDataSource.downloadedModels
+
+ fun getModel(
+ modelName: String,
+ conditions: CustomModelDownloadConditions = CustomModelDownloadConditions.Builder().build(),
+ downloadType: DownloadType = DownloadType.LATEST_MODEL,
+ ) = firebaseMLDataSource.getModel(modelName, conditions, downloadType)
+
+ fun getModelId(
+ modelName: String,
+ conditions: CustomModelDownloadConditions = CustomModelDownloadConditions.Builder().build(),
+ downloadType: DownloadType = DownloadType.LATEST_MODEL,
+ ) = firebaseMLDataSource.getModelId(modelName, conditions, downloadType)
+
+ fun getDownloadedModels() = firebaseMLDataSource.getDownloadedModels()
+
+ fun refreshAvailableModelsList() = firebaseMLDataSource.refreshAvailableModelsList()
+
+ suspend fun deleteAllModels() = firebaseMLDataSource.deleteAllModels()
+
+ fun deleteModel(modelName: String) = firebaseMLDataSource.deleteModel(modelName)
+
+ suspend fun getFilteredDriverAggression(
+ modelName: String,
+ sessionUUID: String
+ ): Flow