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> { + Timber.d("Filtering aggression values for session ${sessionUUID.take(4)}") + val flow = MutableStateFlow>(emptyMap()) + val outputMap = mutableMapOf() + downloadedModels.first().firstOrNull { it.name == modelName }?.let { model -> + val modelFile = model.file + if (modelFile != null) { + sensorEventInteractor.getSyncedEvents(sessionUUID).first { sensorEvents -> + sensorEvents?.let { + val advancedImuSensorData = SensorFusion.fuseSensors( +// interval = 80.milliseconds + Random.nextLong(-10, 10).milliseconds, + accRaw = sensorEvents.filter { + it.type.toInt() == if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + Sensor.TYPE_LINEAR_ACCELERATION else Sensor.TYPE_ACCELEROMETER + }, + accSmooth = emptyList(), + dirX = emptyList(), // FIXME: replace with real values + dirY = emptyList(), // FIXME: replace with real values + dirZ = emptyList(), //sensorEvents.filter { it.type.toInt() == Sensor.TYPE_ROTATION_VECTOR }, + angVel = emptyList(), // FIXME: replace with real values + angAccel = emptyList(), // FIXME: replace with real values + ) + val interpreter = Interpreter(modelFile) + advancedImuSensorData.chunked(400) { chunk -> + interpreter.allocateTensors() + Timber.v( + "Running model with input shape ${ + interpreter.getInputTensor(0).shape().joinToString() + } and output shape ${ + interpreter.getOutputTensor(0).shape().joinToString() + } on chunk of size ${chunk.size}" + ) + if (chunk.size < 400) { + Timber.v("Chunk size is less than 400, skipping...") + return@chunked chunk + } + val startMilli = chunk.first().timestamp + chunk.map { + listOf((it.timestamp - startMilli) / 1000.0).toTypedArray() + // Added a bit of noise + it.accRaw.toList().toTypedArray() + + it.accSmooth.toList().toTypedArray() + + it.dirX.toList().toTypedArray() + + it.dirY.toList().toTypedArray() + + it.dirZ.toList().toTypedArray() +// it.angVel.toList().toTypedArray() + +// it.angAccel.toList().toTypedArray() + }.map { array -> array.map { it.toFloat() } }.toTypedArray() + .let { events -> + val input = ByteBuffer.allocate(8070 * 400 * 16 * java.lang.Float.SIZE / 8).order(ByteOrder.nativeOrder()) + for (i in 0 until 8070) { + events.forEach { sensorValues -> + sensorValues.forEach { input.putFloat(it) } + } + } + val outputSize = 8070 * java.lang.Float.SIZE / 8 + val output = ByteBuffer.allocate(outputSize).order(ByteOrder.nativeOrder()) + interpreter.run(input, output) + output.rewind() + val outputs = mutableListOf() + for (i in 0 until output.asFloatBuffer().capacity()) { + outputs.add(output.asFloatBuffer()[i]) + } + Timber.v("Model outputs: ${outputs.distinct().joinToString()}") + chunk.forEach { advancedImuSensorData -> + outputMap[Instant.ofEpochMilli(advancedImuSensorData.timestamp).toZonedDateTime()] = outputs[0].toDouble() + } + flow.update { outputMap } + } + interpreter.resetVariableTensors() + } + } + sensorEvents != null + } + } + modelFile != null // The model is downloaded + } + return flow.asStateFlow() + } +} \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/domain/interactor/SensorEventInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/SensorEventInteractor.kt index 8fcd7264..5057cbe7 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/SensorEventInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/SensorEventInteractor.kt @@ -18,10 +18,27 @@ package illyan.jay.domain.interactor +import illyan.jay.data.firestore.datasource.SensorEventsFirestoreDataSource +import illyan.jay.data.firestore.datasource.SessionFirestoreDataSource import illyan.jay.data.room.datasource.SensorEventRoomDataSource +import illyan.jay.di.CoroutineScopeIO +import illyan.jay.di.CoroutineScopeMain import illyan.jay.domain.model.DomainSensorEvent +import illyan.jay.domain.model.DomainSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException /** * Acceleration interactor is a layer which aims to be the intermediary @@ -32,8 +49,16 @@ import javax.inject.Singleton */ @Singleton class SensorEventInteractor @Inject constructor( - private val sensorEventRoomDataSource: SensorEventRoomDataSource + private val sensorEventRoomDataSource: SensorEventRoomDataSource, + private val sensorEventsFirestoreDataSource: SensorEventsFirestoreDataSource, + private val authInteractor: AuthInteractor, + private val sessionInteractor: SessionInteractor, + private val sessionFirestoreDataSource: SessionFirestoreDataSource, + @CoroutineScopeIO private val coroutineScopeIO: CoroutineScope, + @CoroutineScopeMain private val coroutineScopeMain: CoroutineScope, ) { + fun getSensorEvents(session: DomainSession) = sensorEventRoomDataSource.getSensorEvents(session) + fun getSensorEvents(sessionUUID: String) = sensorEventRoomDataSource.getSensorEvents(sessionUUID) /** * Save an acceleration data instance. * @@ -49,4 +74,70 @@ class SensorEventInteractor @Inject constructor( */ fun saveSensorEvents(sensorEvents: List) = sensorEventRoomDataSource.saveSensorEvents(sensorEvents) + + val syncedEventsCollectionJobs = 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 getSyncedEvents(sessionUUID: String): StateFlow?> { + Timber.i("Trying to load sensor events for session with ID: $sessionUUID") + if (syncedEventsCollectionJobs[sessionUUID] != null) { + Timber.v("Cancelling current data collection job regarding the sensor events of $sessionUUID") + syncedEventsCollectionJobs[sessionUUID]?.cancel(CancellationException("New data collection job requested")) + syncedEventsCollectionJobs.remove(sessionUUID) + } + val syncedEvents = MutableStateFlow?>(null) + syncedEventsCollectionJobs[sessionUUID] = coroutineScopeMain.launch(start = CoroutineStart.LAZY) { + sensorEventsFirestoreDataSource.getEventsBySession(sessionUUID).collectLatest { remoteEvents -> + coroutineScopeIO.launch { + Timber.v("Found sensor events for session, caching it on disk") + remoteEvents?.let { saveSensorEvents(it) } + } + syncedEvents.update { remoteEvents } + } + } + val session = sessionInteractor.getSession(sessionUUID).first() + if (session != null) { + Timber.v("Found session on disk") + coroutineScopeIO.launch { + val events = getSensorEvents(sessionUUID).first() + if (events.isEmpty()) { + Timber.v("Not found sensor events for session on disk, checking cloud") + if (!authInteractor.isUserSignedIn) { + Timber.i("Not authenticated to access cloud, return an empty list") + syncedEvents.update { emptyList() } + } else { + syncedEventsCollectionJobs[sessionUUID]?.start() + } + } else { + Timber.i("Found path on disk") + syncedEvents.update { events } + } + } + } else { + Timber.v("Not found session on disk, checking cloud") + if (!authInteractor.isUserSignedIn) { + Timber.i("Not authenticated to access cloud, return an empty list") + syncedEvents.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 }) + syncedEventsCollectionJobs[sessionUUID]?.start() + } + } + sessions != null + } + } + } + } + return syncedEvents.asStateFlow() + } } diff --git a/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt index 42543043..844c889e 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.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. * @@ -24,12 +24,15 @@ import com.mapbox.geojson.Point import com.mapbox.search.ReverseGeoOptions import illyan.jay.data.datastore.datasource.AppSettingsDataSource import illyan.jay.data.firestore.datasource.PathFirestoreDataSource +import illyan.jay.data.firestore.datasource.SensorEventsFirestoreDataSource import illyan.jay.data.firestore.datasource.SessionFirestoreDataSource import illyan.jay.data.room.datasource.LocationRoomDataSource import illyan.jay.data.room.datasource.SensorEventRoomDataSource import illyan.jay.data.room.datasource.SessionRoomDataSource import illyan.jay.di.CoroutineScopeIO +import illyan.jay.domain.model.DomainAggression import illyan.jay.domain.model.DomainLocation +import illyan.jay.domain.model.DomainSensorEvent import illyan.jay.domain.model.DomainSession import illyan.jay.util.awaitOperations import illyan.jay.util.runBatch @@ -67,6 +70,7 @@ class SessionInteractor @Inject constructor( private val appSettingsDataSource: AppSettingsDataSource, private val serviceInteractor: ServiceInteractor, private val pathFirestoreDataSource: PathFirestoreDataSource, + private val sensorEventsFirestoreDataSource: SensorEventsFirestoreDataSource, private val firestore: FirebaseFirestore, @CoroutineScopeIO private val coroutineScopeIO: CoroutineScope, ) { @@ -139,13 +143,15 @@ class SessionInteractor @Inject constructor( } } Timber.i("${localOnlySessions.size} sessions are not synced with IDs: ${localOnlySessions.map { it.uuid.take(4) }}") - locationRoomDataSource.getLocations(localOnlySessions.map { it.uuid }).first { locations -> - uploadSessions( - localOnlySessions, - locations, - ) - true - } + val locations = locationRoomDataSource.getLocations(localOnlySessions.map { it.uuid }).first() + val aggressions = locationRoomDataSource.getAggressions(localOnlySessions.map { it.uuid }).first() + val sensorEvents = sensorEventRoomDataSource.getSensorEvents(localOnlySessions.map { it.uuid }).first() + uploadSessions( + localOnlySessions, + locations, + sensorEvents, + aggressions + ) true } } @@ -153,14 +159,18 @@ class SessionInteractor @Inject constructor( suspend fun deleteSyncedSessions() { if (!authInteractor.isUserSignedIn) return Timber.d("Batch created to delete session data for user ${authInteractor.userUUID?.take(4)} from cloud") - firestore.runBatch(2) { batch, onOperationFinished -> + firestore.runBatch(3) { batch, onOperationFinished -> sessionFirestoreDataSource.deleteAllSessions( batch = batch, - onWriteFinished = { onOperationFinished() } + onWriteFinished = onOperationFinished ) pathFirestoreDataSource.deleteLocationsForUser( batch = batch, - onWriteFinished = { onOperationFinished() } + onWriteFinished = onOperationFinished + ) + sensorEventsFirestoreDataSource.deleteSensorEventsForUser( + batch = batch, + onWriteFinished = onOperationFinished ) } } @@ -180,7 +190,7 @@ class SessionInteractor @Inject constructor( batch: WriteBatch, onWriteFinished: () -> Unit = {} ) { - awaitOperations(2) { onOperationFinished -> + awaitOperations(3) { onOperationFinished -> sessionFirestoreDataSource.deleteAllSessions( batch = batch, onWriteFinished = onOperationFinished @@ -189,6 +199,10 @@ class SessionInteractor @Inject constructor( batch = batch, onWriteFinished = onOperationFinished ) + sensorEventsFirestoreDataSource.deleteSensorEventsForUser( + batch = batch, + onWriteFinished = onOperationFinished + ) } onWriteFinished() } @@ -196,6 +210,8 @@ class SessionInteractor @Inject constructor( fun uploadSessions( sessions: List, locations: List, + sensorEvents: List, + aggressions: List, onSuccess: (List) -> Unit = { Timber.i("Uploaded locations for ${sessions.size} sessions") }, ) { if (!authInteractor.isUserSignedIn || sessions.isEmpty()) return @@ -208,7 +224,13 @@ class SessionInteractor @Inject constructor( pathFirestoreDataSource.insertLocations( batch = batch, domainSessions = sessions, - domainLocations = locations + domainLocations = locations, + domainAggressions = aggressions + ) + sensorEventsFirestoreDataSource.insertEvents( + batch = batch, + domainSessions = sessions, + domainSensorEvents = sensorEvents ) }.addOnSuccessListener { onSuccess(sessions) @@ -218,8 +240,10 @@ class SessionInteractor @Inject constructor( fun uploadSession( session: DomainSession, locations: List, + sensorEvents: List, + aggressions: List, onSuccess: (List) -> Unit = { Timber.i("Uploaded locations session ${session.uuid}") }, - ) = uploadSessions(listOf(session), locations, onSuccess) + ) = uploadSessions(listOf(session), locations, sensorEvents, aggressions, onSuccess) /** * Get all session as a Flow. @@ -516,6 +540,7 @@ class SessionInteractor @Inject constructor( stoppedSessions.forEach { session -> sensorEventRoomDataSource.deleteSensorEventsForSession(session.uuid) locationRoomDataSource.deleteLocationForSession(session.uuid) + locationRoomDataSource.deleteAggressionsForSession(session.uuid) } sessionRoomDataSource.deleteSessions(stoppedSessions) } @@ -574,5 +599,32 @@ class SessionInteractor @Inject constructor( true } } + + suspend fun uploadSessionAggressions(aggressions: List) { + if (!authInteractor.isUserSignedIn) { + Timber.i("User is not signed in, not uploading aggressions") + return + } + if (aggressions.isEmpty()) { + Timber.i("No aggressions to upload") + return + } + val sessionUUID = aggressions.first().sessionUUID + if (syncedSessions.value?.map { it.uuid }?.contains(sessionUUID) == true) { + Timber.i("Uploading ${aggressions.size} aggressions to the cloud") + val location = locationRoomDataSource.getLocations(sessionUUID).first() + val sessions = listOf(getSession(sessionUUID).first()!!) + firestore.runBatch { batch -> + pathFirestoreDataSource.insertLocations( + batch = batch, + domainSessions = sessions, + domainLocations = location, + domainAggressions = aggressions + ) + } + } else { + Timber.i("Session $sessionUUID is not synced, not uploading aggressions") + } + } } diff --git a/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt index bad496d0..507ff546 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt @@ -34,6 +34,7 @@ class UserInteractor @Inject constructor( private val settingsInteractor: SettingsInteractor, private val userFirestoreDataFlow: UserFirestoreDataFlow, private val sessionInteractor: SessionInteractor, + private val modelInteractor: ModelInteractor, private val firestore: FirebaseFirestore, ) { // TODO: estimate cache size in the future @@ -73,6 +74,7 @@ class UserInteractor @Inject constructor( if (authInteractor.isUserSignedIn) { sessionInteractor.deleteOwnedSessions() settingsInteractor.deleteLocalUserPreferences() + modelInteractor.deleteAllModels() } } diff --git a/app/src/main/java/illyan/jay/domain/model/AdvancedImuSensorData.kt b/app/src/main/java/illyan/jay/domain/model/AdvancedImuSensorData.kt new file mode 100644 index 00000000..5236e58b --- /dev/null +++ b/app/src/main/java/illyan/jay/domain/model/AdvancedImuSensorData.kt @@ -0,0 +1,31 @@ +/* + * 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.domain.model + +data class AdvancedImuSensorData( + val dirX: Triple, // XYZ + val dirY: Triple, // XYZ + val dirZ: Triple, // XYZ + val accRaw: Triple, // XYZ + val accSmooth: Triple, // XYZ + val angVel: Triple, // XYZ + val angAccel: Triple, // XYZ + val timestamp: Long, // Milliseconds + val sessionUUID: Long = 0L, +) \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/domain/model/DomainAggression.kt b/app/src/main/java/illyan/jay/domain/model/DomainAggression.kt new file mode 100644 index 00000000..1fe004a2 --- /dev/null +++ b/app/src/main/java/illyan/jay/domain/model/DomainAggression.kt @@ -0,0 +1,28 @@ +/* + * 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.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DomainAggression( + val sessionUUID: String, + val timestamp: Long, // in millis + val aggression: Float +) diff --git a/app/src/main/java/illyan/jay/service/JayService.kt b/app/src/main/java/illyan/jay/service/JayService.kt index 51bf19cb..7fc95ac7 100644 --- a/app/src/main/java/illyan/jay/service/JayService.kt +++ b/app/src/main/java/illyan/jay/service/JayService.kt @@ -96,37 +96,44 @@ class JayService @Inject constructor() : BaseService() { }") // 2. Select preferred sensors if found - val accelerationSensorType = if (sensorTypes.contains(Sensor.TYPE_LINEAR_ACCELERATION)) { - Sensor.TYPE_LINEAR_ACCELERATION to Sensor.STRING_TYPE_LINEAR_ACCELERATION - } else { - Sensor.TYPE_ACCELEROMETER to Sensor.STRING_TYPE_ACCELEROMETER - } - val rotationSensorType = if (sensorTypes.contains(Sensor.TYPE_ROTATION_VECTOR)) { - Sensor.TYPE_ROTATION_VECTOR to Sensor.STRING_TYPE_ROTATION_VECTOR + val accelerationSensorOptions = if (sensorTypes.contains(Sensor.TYPE_LINEAR_ACCELERATION)) { + SensorOptions( + Sensor.TYPE_LINEAR_ACCELERATION, + Sensor.STRING_TYPE_LINEAR_ACCELERATION, + SensorManager.SENSOR_DELAY_NORMAL + ) } else { - Sensor.TYPE_ORIENTATION to Sensor.STRING_TYPE_ORIENTATION - } - val magneticFieldSensorType = - Sensor.TYPE_MAGNETIC_FIELD to Sensor.STRING_TYPE_MAGNETIC_FIELD - - // 3. Register listeners - // TODO: Use set delay from user preferences in the future, default is NORMAL - listOf( SensorOptions( - accelerationSensorType.first, - accelerationSensorType.second, + Sensor.TYPE_ACCELEROMETER, + Sensor.STRING_TYPE_ACCELEROMETER, SensorManager.SENSOR_DELAY_NORMAL - ), + ) + } + val rotationSensorOptions = if (sensorTypes.contains(Sensor.TYPE_ROTATION_VECTOR)) { SensorOptions( - rotationSensorType.first, - rotationSensorType.second, + Sensor.TYPE_ROTATION_VECTOR, + Sensor.STRING_TYPE_ROTATION_VECTOR, SensorManager.SENSOR_DELAY_NORMAL - ), + ) + } else { SensorOptions( - magneticFieldSensorType.first, - magneticFieldSensorType.second, + Sensor.TYPE_ORIENTATION, + Sensor.STRING_TYPE_ORIENTATION, SensorManager.SENSOR_DELAY_NORMAL ) + } + val magneticFieldSensorType = SensorOptions( + Sensor.TYPE_MAGNETIC_FIELD, + Sensor.STRING_TYPE_MAGNETIC_FIELD, + SensorManager.SENSOR_DELAY_NORMAL + ) + + // 3. Register listeners + // TODO: Use set delay from user preferences in the future, default is NORMAL + listOf( + accelerationSensorOptions, + rotationSensorOptions, + magneticFieldSensorType ).forEach { if (sensorTypes.contains(it.sensorType)) { Timber.d("Registering listener to sensor type: ${it.sensorTypeString}") diff --git a/app/src/main/java/illyan/jay/ui/components/Tooltip.kt b/app/src/main/java/illyan/jay/ui/components/Tooltip.kt index 3c6f74fd..dd41d892 100644 --- a/app/src/main/java/illyan/jay/ui/components/Tooltip.kt +++ b/app/src/main/java/illyan/jay/ui/components/Tooltip.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. * @@ -34,11 +34,12 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.PlainTooltipBox -import androidx.compose.material3.PlainTooltipState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberPlainTooltipState +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults.rememberPlainTooltipPositionProvider +import androidx.compose.material3.TooltipState +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -78,7 +79,7 @@ fun TooltipElevatedCard( onDismissTooltip: () -> Unit = {}, content: @Composable () -> Unit ) { - val tooltipState = rememberPlainTooltipState() + val tooltipState = rememberTooltipState() val coroutineScope = rememberCoroutineScope() val tryShowTooltip = { coroutineScope.launch { @@ -134,7 +135,7 @@ fun TooltipButton( onDismissTooltip: () -> Unit = {}, content: @Composable RowScope.() -> Unit ) { - val tooltipState = rememberPlainTooltipState() + val tooltipState = rememberTooltipState() val coroutineScope = rememberCoroutineScope() val tryShowTooltip = { coroutineScope.launch { tooltipState.show() } } ContentWithTooltip( @@ -178,7 +179,7 @@ fun TooltipButton( @Composable fun ContentWithTooltip( modifier: Modifier = Modifier, - tooltipState: PlainTooltipState = rememberPlainTooltipState(), + tooltipState: TooltipState = rememberTooltipState(), tooltip: @Composable () -> Unit, disabledTooltip: @Composable (() -> Unit)? = null, enabled: Boolean = true, @@ -199,12 +200,11 @@ fun ContentWithTooltip( currentTooltip = if (enabled || disabledTooltip == null) tooltip else disabledTooltip } } - PlainTooltipBox( + TooltipBox( + positionProvider = rememberPlainTooltipPositionProvider(), modifier = modifier, - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - tooltip = currentTooltip, - tooltipState = tooltipState, + state = tooltipState, + tooltip = { currentTooltip() }, ) { content() } diff --git a/app/src/main/java/illyan/jay/ui/home/Home.kt b/app/src/main/java/illyan/jay/ui/home/Home.kt index fc1186ea..cfc5c26b 100644 --- a/app/src/main/java/illyan/jay/ui/home/Home.kt +++ b/app/src/main/java/illyan/jay/ui/home/Home.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. * @@ -28,6 +28,7 @@ import android.Manifest import android.app.Activity import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.net.Uri import android.os.Parcelable import androidx.compose.animation.AnimatedVisibility @@ -435,9 +436,14 @@ fun HomeScreen( onDispose { viewModel.dispose() } } val density = LocalDensity.current.density - val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp + val configuration = LocalConfiguration.current + val maxHeight = when (configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> configuration.screenHeightDp + Configuration.ORIENTATION_LANDSCAPE -> configuration.screenWidthDp + else -> configuration.screenHeightDp + }.dp LaunchedEffect(density) { _density.update { density } } - LaunchedEffect(screenHeightDp) { _screenHeight.update { screenHeightDp } } + LaunchedEffect(maxHeight) { _screenHeight.update { maxHeight } } ConstraintLayout( modifier = Modifier .fillMaxSize() @@ -1096,5 +1102,5 @@ private fun SearchNavHost( } fun BottomSheetState.getOffsetAsDp(density: Float): Dp { - return (requireOffset() / density).dp + return (try { requireOffset() } catch (e: Exception) { 0f } / density).dp } \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt b/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt index bcfc8c67..e819e23e 100644 --- a/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt +++ b/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt @@ -18,15 +18,17 @@ package illyan.jay.ui.libraries +import android.content.res.Configuration import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -59,7 +61,7 @@ import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.compose.scrollbar.drawVerticalScrollbar import illyan.jay.R import illyan.jay.ui.components.JayDialogContent -import illyan.jay.ui.components.PreviewAccessibility +import illyan.jay.ui.components.PreviewAll import illyan.jay.ui.destinations.LibraryDialogScreenDestination import illyan.jay.ui.libraries.model.UiLibrary import illyan.jay.ui.profile.ProfileNavGraph @@ -86,10 +88,15 @@ fun LibrariesDialogContent( libraries: List = emptyList(), onSelectLibrary: (UiLibrary) -> Unit = {}, ) { - val screenHeightDp = LocalConfiguration.current.screenHeightDp + val configuration = LocalConfiguration.current + val maxHeight = when (configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> configuration.screenHeightDp + Configuration.ORIENTATION_LANDSCAPE -> configuration.screenWidthDp + else -> configuration.screenHeightDp + } JayDialogContent( modifier = modifier, - textModifier = Modifier.heightIn(max = (screenHeightDp * 0.55f).dp), + textModifier = Modifier.heightIn(max = (maxHeight * 0.55f).dp), title = { LibrariesTitle() }, text = { LibrariesScreen( @@ -97,6 +104,7 @@ fun LibrariesDialogContent( onSelectLibrary = onSelectLibrary, ) }, + textPaddingValues = PaddingValues() ) } @@ -120,18 +128,11 @@ fun LibrariesScreen( LazyColumn( modifier = modifier .drawVerticalScrollbar(state = lazyListState) - .clip(RoundedCornerShape(verticalContentPadding)), + .clip(RoundedCornerShape(verticalContentPadding)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), ) { - itemsIndexed(libraries) { index, item -> - val cardColors = if (index.mod(2) == 0) { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - ) - } else { - CardDefaults.cardColors( - containerColor = Color.Transparent - ) - } + items(libraries) { item -> + val cardColors = CardDefaults.cardColors(containerColor = Color.Transparent) LibraryItem( library = item, onClick = { onSelectLibrary(item) }, @@ -159,7 +160,7 @@ fun LibraryItem( ConstraintLayout( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 6.dp), ) { val (item, icon) = createRefs() createHorizontalChain( @@ -186,9 +187,7 @@ fun LibraryItem( } ) { item { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + Column { Text( text = library.name, style = MaterialTheme.typography.titleSmall, @@ -205,7 +204,7 @@ fun LibraryItem( shownText?.let { Text( text = it, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = AlertDialogDefaults.textContentColor, ) } @@ -217,7 +216,7 @@ fun LibraryItem( } } -@PreviewAccessibility +@PreviewAll @Composable private fun LibrariesDialogContentPreview() { val libraries = LibrariesViewModel.Libraries diff --git a/app/src/main/java/illyan/jay/ui/library/Library.kt b/app/src/main/java/illyan/jay/ui/library/Library.kt index 9e960178..b5ec9729 100644 --- a/app/src/main/java/illyan/jay/ui/library/Library.kt +++ b/app/src/main/java/illyan/jay/ui/library/Library.kt @@ -18,6 +18,7 @@ package illyan.jay.ui.library +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.animateContentSize @@ -73,10 +74,15 @@ fun LibraryDialogContent( modifier: Modifier = Modifier, library: UiLibrary, ) { - val screenHeightDp = LocalConfiguration.current.screenHeightDp + val configuration = LocalConfiguration.current + val maxHeight = when (configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> configuration.screenHeightDp + Configuration.ORIENTATION_LANDSCAPE -> configuration.screenWidthDp + else -> configuration.screenHeightDp + } JayDialogContent( modifier = modifier, - textModifier = Modifier.heightIn(max = (screenHeightDp * 0.5f).dp), + textModifier = Modifier.heightIn(max = (maxHeight * 0.55f).dp), title = { LibraryTitle( name = library.name, diff --git a/app/src/main/java/illyan/jay/ui/menu/Menu.kt b/app/src/main/java/illyan/jay/ui/menu/Menu.kt index 4f598beb..8b2b30d2 100644 --- a/app/src/main/java/illyan/jay/ui/menu/Menu.kt +++ b/app/src/main/java/illyan/jay/ui/menu/Menu.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. * @@ -76,6 +76,7 @@ import com.ramcosta.composedestinations.annotation.NavGraph import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import illyan.jay.BuildConfig import illyan.jay.MainActivity import illyan.jay.R import illyan.jay.domain.model.Theme @@ -162,12 +163,14 @@ fun MenuContent( .padding(DefaultScreenOnSheetPadding), state = gridState ) { - item { - MenuItemCard( - title = stringResource(R.string.navigate_to_bme), - icon = Icons.Rounded.TravelExplore, - onClick = onNavigateToBme, - ) + if (BuildConfig.DEBUG) { + item { + MenuItemCard( + title = stringResource(R.string.navigate_to_bme), + icon = Icons.Rounded.TravelExplore, + onClick = onNavigateToBme, + ) + } } item { MenuItemCard( diff --git a/app/src/main/java/illyan/jay/ui/session/Session.kt b/app/src/main/java/illyan/jay/ui/session/Session.kt index 58eda7c3..ae9d499b 100644 --- a/app/src/main/java/illyan/jay/ui/session/Session.kt +++ b/app/src/main/java/illyan/jay/ui/session/Session.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,6 +18,7 @@ package illyan.jay.ui.session +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.animateContentSize @@ -31,12 +32,13 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowRightAlt +import androidx.compose.material.icons.automirrored.rounded.ArrowRightAlt import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text @@ -88,6 +90,7 @@ import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.jay.R import illyan.jay.ui.components.MediumCircularProgressIndicator import illyan.jay.ui.components.PreviewAccessibility +import illyan.jay.ui.components.SmallCircularProgressIndicator import illyan.jay.ui.home.RoundedCornerRadius import illyan.jay.ui.home.mapView import illyan.jay.ui.home.sheetState @@ -210,6 +213,33 @@ fun gpsAccuracyGradient( } ) +fun aggressionGradient( + locations: List, + leastAggressiveColor: Color = Color.Green, + mostAggressiveColor: Color = Color.Red, + aggressions: List? = null, + minAggression: Float = 0.2f, + maxAggression: Float = 1f, + loadAggressions: () -> Unit = {}, +): Expression { + + if (aggressions == null) { + loadAggressions() + return defaultGradient() + } + val aggressionAtLocation = locations.zip(aggressions).toMap() + return createGradientFromLocations( + locations = locations, + start = leastAggressiveColor, + stop = mostAggressiveColor, + getColorFraction = { location -> + val aggression = aggressionAtLocation[location]?.coerceIn(minAggression, maxAggression) + ?: ((minAggression + maxAggression) / 2) + (aggression - minAggression) / (maxAggression - minAggression) + } + ) +} + @OptIn(ExperimentalMaterialApi::class) @MenuNavGraph @Destination @@ -223,7 +253,7 @@ fun SessionScreen( LaunchedEffect(Unit) { viewModel.load(sessionUUID) } - val gradientFilter by viewModel.gradientFilter.collectAsStateWithLifecycle() + var selectedGradientFilter by rememberSaveable { mutableStateOf(GradientFilter.Default) } var previousOffset by rememberSaveable { mutableFloatStateOf(0f) } var currentOffset by rememberSaveable { mutableFloatStateOf(sheetState.requireOffset()) } var noMoreOffsetChanges by rememberSaveable { mutableStateOf(false) } @@ -251,7 +281,7 @@ fun SessionScreen( if (!flownToPath) { Timber.d("Try to fly") tryFlyToPath( - path = path!!.map { location -> + path = it.map { location -> Point.fromLngLat( location.latLng.longitude, location.latLng.latitude @@ -264,11 +294,19 @@ fun SessionScreen( } } val context = LocalContext.current + val aggressions by viewModel.aggressions.collectAsStateWithLifecycle() + LaunchedEffect(aggressions) { + if (aggressions?.isEmpty() == true) { + Toast.makeText(context, context.getText(R.string.aggression_load_failed_no_sensor_data), Toast.LENGTH_SHORT).show() + } + } val density = LocalDensity.current.density val mapMarkers by mapMarkers.collectAsStateWithLifecycle() + DisposableEffect( path, - gradientFilter + selectedGradientFilter, + aggressions ) { val sortedLocations = path?.sortedBy { it.zonedDateTime }?.map { it.latLng } val startPoint = sortedLocations?.first() @@ -298,7 +336,7 @@ fun SessionScreen( lineJoin(LineJoin.ROUND) lineWidth(lineWidth) lineGradient( - when(gradientFilter) { + when (selectedGradientFilter) { GradientFilter.Default -> defaultGradient() GradientFilter.Velocity -> velocityGradient( locations = path ?: emptyList(), @@ -315,6 +353,13 @@ fun SessionScreen( leastAccurateColor = Color.Red, mostAccurateColor = Color.Green ) + GradientFilter.Aggression -> aggressionGradient( + locations = path ?: emptyList(), + leastAggressiveColor = Color.Green, + mostAggressiveColor = Color.Red, + aggressions = aggressions, + loadAggressions = { viewModel.generateAggressionByModel(sessionUUID) } + ) } ) }, @@ -350,14 +395,17 @@ fun SessionScreen( } } val session by viewModel.session.collectAsStateWithLifecycle() + val isModelAvailable by viewModel.isModelAvailable.collectAsStateWithLifecycle() SessionDetailsScreen( modifier = Modifier .fillMaxWidth() .padding(DefaultScreenOnSheetPadding), session = session, path = path, - gradientFilter = gradientFilter, - setGradientFilter = viewModel::setGradientFilter, + aggressions = aggressions, + isModelAvailable = isModelAvailable, + selectedGradientFilter = selectedGradientFilter, + setGradientFilter = { selectedGradientFilter = it }, ) } @@ -366,7 +414,9 @@ fun SessionDetailsScreen( modifier: Modifier = Modifier, session: UiSession? = null, path: List? = null, - gradientFilter: GradientFilter = GradientFilter.Default, + aggressions: List? = null, + isModelAvailable: Boolean = false, + selectedGradientFilter: GradientFilter = GradientFilter.Default, setGradientFilter: (GradientFilter) -> Unit = {} ) { Column( @@ -394,7 +444,7 @@ fun SessionDetailsScreen( ) } Icon( - imageVector = Icons.Rounded.ArrowRightAlt, contentDescription = "", + imageVector = Icons.AutoMirrored.Rounded.ArrowRightAlt, contentDescription = "", tint = MaterialTheme.colorScheme.onSurface, ) Crossfade( @@ -498,10 +548,8 @@ fun SessionDetailsScreen( stringResource(R.string.session_id) to session?.uuid ), ) - val selectedTabIndex = gradientFilter.ordinal - TabRow( - modifier = Modifier - .padding(horizontal = MenuItemPadding), + val selectedTabIndex = selectedGradientFilter.ordinal + ScrollableTabRow( divider = {}, selectedTabIndex = selectedTabIndex, indicator = { @@ -511,24 +559,39 @@ fun SessionDetailsScreen( .padding(horizontal = MenuItemPadding) .clip(RoundedCornerShape(percent = 100)) ) - } + }, + edgePadding = 8.dp ) { - GradientFilter.values().forEach { + GradientFilter.entries.forEach { filter -> + val isEnabled = filter != GradientFilter.Aggression || isModelAvailable Tab( - modifier = Modifier.clip(RoundedCornerShape(MenuItemPadding)), - selected = it == gradientFilter, - onClick = { setGradientFilter(it) }, + modifier = Modifier + .padding(horizontal = MenuItemPadding) + .clip(RoundedCornerShape(MenuItemPadding)), + selected = filter == selectedGradientFilter, + onClick = { setGradientFilter(filter) }, + enabled = isEnabled, + unselectedContentColor = LocalContentColor.current.copy(if (isEnabled) 1f else 0.5f), text = { - Text( - text = stringResource( - when(it) { - GradientFilter.Default -> R.string.gradient_filter_default - GradientFilter.Velocity -> R.string.gradient_filter_velocity - GradientFilter.Elevation -> R.string.gradient_filter_elevation - GradientFilter.GpsAccuracy -> R.string.gradient_filter_gps_accuracy - } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + AnimatedVisibility(visible = aggressions == null && selectedGradientFilter == GradientFilter.Aggression && filter == selectedGradientFilter) { + SmallCircularProgressIndicator() + } + Text( + text = stringResource( + when (filter) { + GradientFilter.Default -> R.string.gradient_filter_default + GradientFilter.Velocity -> R.string.gradient_filter_velocity + GradientFilter.Elevation -> R.string.gradient_filter_elevation + GradientFilter.GpsAccuracy -> R.string.gradient_filter_gps_accuracy + GradientFilter.Aggression -> R.string.gradient_filter_aggression + } + ) ) - ) + } } ) } diff --git a/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt b/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt index 1f8353b0..06390770 100644 --- a/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/session/SessionViewModel.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. * @@ -24,11 +24,14 @@ import com.google.maps.android.ktx.utils.sphericalPathLength import dagger.hilt.android.lifecycle.HiltViewModel import illyan.jay.di.CoroutineDispatcherIO import illyan.jay.domain.interactor.LocationInteractor +import illyan.jay.domain.interactor.ModelInteractor +import illyan.jay.domain.interactor.SensorEventInteractor import illyan.jay.domain.interactor.SessionInteractor -import illyan.jay.ui.session.model.GradientFilter +import illyan.jay.domain.model.DomainAggression import illyan.jay.ui.session.model.UiLocation import illyan.jay.ui.session.model.UiSession import illyan.jay.ui.session.model.toUiModel +import illyan.jay.util.toZonedDateTime import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -36,22 +39,46 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber +import java.time.Instant +import java.time.ZonedDateTime import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs @HiltViewModel class SessionViewModel @Inject constructor( private val sessionInteractor: SessionInteractor, private val locationInteractor: LocationInteractor, + private val sensorEventInteractor: SensorEventInteractor, + private val modelInteractor: ModelInteractor, @CoroutineDispatcherIO private val dispatcherIO: CoroutineDispatcher, ) : ViewModel() { + private val _isModelAvailable = MutableStateFlow(false) + val isModelAvailable = _isModelAvailable.asStateFlow() + private val _aggressions = MutableStateFlow?>(null) + val aggressions = _aggressions + .asStateFlow() + .map { aggressions -> aggressions?.map { it.value.toFloat() } } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _path = MutableStateFlow?>(null) - val path = _path.asStateFlow() + val path = _path.combine(_aggressions) { path, aggressions -> + path?.map { location -> + // Find closest zonedDateTime of a location for each aggression + val closestAggressionToLocationTimestamp = aggressions?.minByOrNull { + // Time difference + abs(it.key.toInstant().toEpochMilli() - location.zonedDateTime.toInstant().toEpochMilli()) + }?.value +// Timber.d("Aggression difference: $closestAggressionToLocationTimestamp") + location.copy(aggression = closestAggressionToLocationTimestamp?.toFloat()) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, _path.value) private val _session = MutableStateFlow(null) val session = combine( @@ -63,11 +90,17 @@ class SessionViewModel @Inject constructor( ) }.stateIn(viewModelScope, SharingStarted.Eagerly, _session.value) - private val _gradientFilter = MutableStateFlow(GradientFilter.Default) - val gradientFilter = _gradientFilter.asStateFlow() - private val jobs = mutableListOf() + init { + viewModelScope.launch(dispatcherIO) { + modelInteractor.downloadedModels.collect { models -> + Timber.d("${models.size} models available") + _isModelAvailable.update { models.isNotEmpty() } + } + } + } + fun load(sessionUUID: String) { jobs.forEach { it.cancel(CancellationException("Requested reload to data collection job, cancelling running jobs"))} jobs += viewModelScope.launch(dispatcherIO) { @@ -90,9 +123,45 @@ class SessionViewModel @Inject constructor( } } } + jobs += viewModelScope.launch(dispatcherIO) { + locationInteractor.getSyncedPathAggressions(sessionUUID).collectLatest { aggressions -> + Timber.d("Loaded ${aggressions?.size} aggressions for session with ID: $sessionUUID") + _aggressions.update { aggressions?.associate { + Instant.ofEpochMilli(it.timestamp).toZonedDateTime() to it.aggression.toDouble() + } } + } + } } - fun setGradientFilter(filter: GradientFilter) { - _gradientFilter.update { filter } + fun generateAggressionByModel(sessionUUID: String) { + viewModelScope.launch(dispatcherIO) { + if (!aggressions.first().isNullOrEmpty()) { + Timber.d("Aggressions already generated for session with ID ${sessionUUID.take(4)}, not generating new ones") + return@launch + } + if (modelInteractor.downloadedModels.first().isEmpty()) { + Timber.d("No downloaded models found") + return@launch + } + Timber.d("Loading aggression for session with ID: ${sessionUUID.take(4)}") + modelInteractor.downloadedModels.first().firstOrNull()?.let { model -> + viewModelScope.launch(dispatcherIO) { + modelInteractor.getFilteredDriverAggression( + model.name, + sessionUUID + ).collectLatest { filteredAggressions -> + Timber.d("Loaded ${filteredAggressions.size} aggressions for session with ID: ${sessionUUID.take(4)}") + val aggressions = filteredAggressions.map { + DomainAggression(sessionUUID, it.key.toInstant().toEpochMilli(), it.value.toFloat()) + } + locationInteractor.saveAggressions(aggressions) + if (sessionInteractor.syncedSessions.first()?.map { it.uuid }?.contains(sessionUUID) == true) { + sessionInteractor.uploadSessionAggressions(aggressions) + } + _aggressions.update { filteredAggressions } + } + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt b/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt index df78b185..fb1b8cd8 100644 --- a/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt +++ b/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt @@ -22,5 +22,6 @@ enum class GradientFilter { Default, Velocity, Elevation, - GpsAccuracy + GpsAccuracy, + Aggression, } \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt b/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt index 299b2ecb..b4872f10 100644 --- a/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt +++ b/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt @@ -32,9 +32,10 @@ data class UiLocation( var altitude: Short, var speedAccuracy: Float, // in meters per second var verticalAccuracy: Short, // in meters + var aggression: Float? // Arbitrary unit, mostly in range [0.2, 1.0] ) -fun DomainLocation.toUiModel() = UiLocation( +fun DomainLocation.toUiModel(aggression: Float? = null) = UiLocation( zonedDateTime = zonedDateTime, latLng = latLng, speed = speed, @@ -44,4 +45,5 @@ fun DomainLocation.toUiModel() = UiLocation( altitude = altitude, speedAccuracy = speedAccuracy, verticalAccuracy = verticalAccuracy, + aggression = aggression ) diff --git a/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt b/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt index 05774bef..5f6c5354 100644 --- a/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.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. * @@ -25,6 +25,7 @@ import illyan.jay.data.datastore.datasource.AppSettingsDataSource import illyan.jay.di.CoroutineDispatcherIO import illyan.jay.domain.interactor.AuthInteractor import illyan.jay.domain.interactor.LocationInteractor +import illyan.jay.domain.interactor.SensorEventInteractor import illyan.jay.domain.interactor.SessionInteractor import illyan.jay.domain.model.DomainSession import illyan.jay.ui.sessions.model.UiSession @@ -54,6 +55,7 @@ import kotlin.coroutines.cancellation.CancellationException class SessionsViewModel @Inject constructor( private val sessionInteractor: SessionInteractor, private val locationInteractor: LocationInteractor, + private val sensorEventInteractor: SensorEventInteractor, authInteractor: AuthInteractor, appSettingsDataSource: AppSettingsDataSource, @CoroutineDispatcherIO private val dispatcherIO: CoroutineDispatcher @@ -265,7 +267,9 @@ class SessionsViewModel @Inject constructor( viewModelScope.launch(dispatcherIO) { sessionInteractor.getSession(uuid).first()?.let { session -> val locations = locationInteractor.getLocations(uuid).first() - sessionInteractor.uploadSession(session, locations) + val events = sensorEventInteractor.getSensorEvents(uuid).first() + val aggressions = locationInteractor.getAggressions(uuid).first() + sessionInteractor.uploadSession(session, locations, events, aggressions) } } } diff --git a/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt b/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt index a3fafb0d..1872a3be 100644 --- a/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt +++ b/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt @@ -18,6 +18,7 @@ package illyan.jay.ui.settings.data +import android.content.res.Configuration import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade @@ -108,7 +109,12 @@ fun DataSettingsDialogContent( onDeleteAll: () -> Unit = {}, onNavigateUp: () -> Unit = {}, ) { - val screenHeightDp = LocalConfiguration.current.screenHeightDp + val configuration = LocalConfiguration.current + val maxHeight = when (configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> configuration.screenHeightDp + Configuration.ORIENTATION_LANDSCAPE -> configuration.screenWidthDp + else -> configuration.screenHeightDp + } JayDialogContent( modifier = modifier, icon = { @@ -126,7 +132,7 @@ fun DataSettingsDialogContent( textPaddingValues = PaddingValues(), text = { DataSettingsScreen( - modifier = Modifier.heightIn(max = (screenHeightDp * 0.4f).dp), + modifier = Modifier.heightIn(max = (maxHeight * 0.5f).dp), onDeleteCached = onDeleteCached, onDeletePublic = onDeletePublic, onDeleteSynced = onDeleteSynced, diff --git a/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt b/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt new file mode 100644 index 00000000..71f57a1d --- /dev/null +++ b/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt @@ -0,0 +1,284 @@ +/* + * 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.ui.settings.ml + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Done +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import illyan.jay.R +import illyan.jay.ui.components.JayDialogContent +import illyan.jay.ui.components.JayDialogSurface +import illyan.jay.ui.components.MediumCircularProgressIndicator +import illyan.jay.ui.components.PreviewAccessibility +import illyan.jay.ui.profile.ProfileNavGraph +import illyan.jay.ui.settings.ml.model.ModelState +import illyan.jay.ui.settings.ml.model.UiModel +import illyan.jay.ui.settings.user.BasicSetting +import illyan.jay.ui.theme.JayTheme + +@ProfileNavGraph +@Destination +@Composable +fun MLSettingsDialogScreen( + viewModel: MLSettingsViewModel = hiltViewModel(), + destinationsNavigator: DestinationsNavigator = EmptyDestinationsNavigator, +) { + val uiModels by viewModel.models.collectAsStateWithLifecycle() + MLSettingsDialogContent( + uiModels = uiModels, + onRefreshModelList = viewModel::refreshModelList, + onDeleteAllModels = viewModel::onDeleteAllModels, + onDownloadModel = viewModel::downloadModel, + ) +} + +@Composable +fun MLSettingsDialogContent( + modifier: Modifier = Modifier, + uiModels: List = emptyList(), + onRefreshModelList: () -> Unit = {}, + onDeleteAllModels: () -> Unit = {}, + onDownloadModel: (String) -> Unit = {}, +) { + var startup by rememberSaveable { mutableStateOf(true) } + Crossfade(targetState = startup, label = "ML Settings Dialog Content") { + if (it) { + MLWarningScreen(onAccept = { startup = false }) + } else { + MLSettingsScreen( + modifier = modifier, + uiModels = uiModels, + onDownloadModel = onDownloadModel, + onRefreshModelList = onRefreshModelList, + onDeleteAllModels = onDeleteAllModels, + ) + } + } +} + +@Composable +fun MLSettingsScreen( + modifier: Modifier = Modifier, + uiModels: List = emptyList(), + onDownloadModel: (String) -> Unit = {}, + onRefreshModelList: () -> Unit = {}, + onDeleteAllModels: () -> Unit = {}, +) { + JayDialogContent( + modifier = modifier, + title = { + Text(text = stringResource(R.string.machine_learning)) + }, + text = { + MLSettingsModelList( + uiModels = uiModels, + downloadModel = onDownloadModel, + ) + }, + containerColor = Color.Transparent, + buttons = { + MLSettingsButtons( + modifier = Modifier.fillMaxWidth(), + onRefreshModelList = onRefreshModelList, + onDeleteAllModels = onDeleteAllModels, + ) + } + ) +} + +@Composable +fun MLWarningScreen( + modifier: Modifier = Modifier, + onAccept: () -> Unit = {}, +) { + JayDialogContent( + modifier = modifier, + icon = { + Icon( + modifier = Modifier + .size(48.dp) + .align(Alignment.TopCenter), + imageVector = Icons.Rounded.RestartAlt, + contentDescription = "" + ) + }, + title = { + Text( + modifier = Modifier.align(Alignment.TopCenter), + text = stringResource(R.string.restart_required), + textAlign = TextAlign.Center + ) + }, + text = { + LazyColumn { + item { + Text(text = stringResource(R.string.restart_required_description)) + } + } + }, + buttons = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Button(onClick = onAccept) { + Text(text = stringResource(R.string.ok)) + } + } + } + ) +} + +@Composable +fun MLSettingsModelList( + modifier: Modifier = Modifier, + uiModels: List = emptyList(), + downloadModel: (String) -> Unit = {}, +) { + Crossfade(targetState = uiModels.isEmpty(), label = "ML Model list") { isEmpty -> + if (isEmpty) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.no_available_models_found), + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + LazyColumn( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), + ) { + items(uiModels) { + BasicSetting( + title = it.name, + label = { + Crossfade( + modifier = Modifier.animateContentSize(), + targetState = it.state, + label = "ML Model download state" + ) { state -> + when (state) { + ModelState.Available -> TextButton(onClick = { downloadModel(it.name) }) { + Text(text = stringResource(R.string.download)) + Icon(imageVector = Icons.Rounded.Download, contentDescription = "") + } + ModelState.Downloading -> TextButton(onClick = {}) { + MediumCircularProgressIndicator() + } + ModelState.Downloaded -> TextButton(onClick = {}) { + Icon(imageVector = Icons.Rounded.Done, contentDescription = "") + } + } + } + }, + onClick = { if (it.state == ModelState.Available) downloadModel(it.name) }, + ) + } + } + } + } +} + +@Composable +fun MLSettingsButtons( + modifier: Modifier = Modifier, + onRefreshModelList: () -> Unit = {}, + onDeleteAllModels: () -> Unit = {}, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onDeleteAllModels) { + Text(text = stringResource(R.string.delete_all)) + Icon(imageVector = Icons.Rounded.Delete, contentDescription = "") + } + TextButton(onClick = onRefreshModelList) { + Text(text = stringResource(R.string.refresh)) + Icon(imageVector = Icons.Rounded.Refresh, contentDescription = "") + } + } +} + +@PreviewAccessibility +@Composable +fun MLSettingsScreenPreview() { + JayTheme { + JayDialogSurface { + MLSettingsScreen( + uiModels = listOf( + UiModel("Available Model", ModelState.Available), + UiModel("Downloading Model", ModelState.Downloading), + UiModel("Downloaded Model", ModelState.Downloaded), + ) + ) + } + } +} + +@PreviewAccessibility +@Composable +fun MLWarningScreenPreview() { + JayTheme { + JayDialogSurface { + MLWarningScreen() + } + } +} diff --git a/app/src/main/java/illyan/jay/ui/settings/ml/MLSettingsViewModel.kt b/app/src/main/java/illyan/jay/ui/settings/ml/MLSettingsViewModel.kt new file mode 100644 index 00000000..98738221 --- /dev/null +++ b/app/src/main/java/illyan/jay/ui/settings/ml/MLSettingsViewModel.kt @@ -0,0 +1,72 @@ +/* + * 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.ui.settings.ml + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import illyan.jay.domain.interactor.ModelInteractor +import illyan.jay.ui.settings.ml.model.ModelState +import illyan.jay.ui.settings.ml.model.UiModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MLSettingsViewModel @Inject constructor( + private val modelInteractor: ModelInteractor, +) : ViewModel() { + private val availableModels = modelInteractor.availableModels + private val downloadingModels = modelInteractor.downloadingModels + private val downloadedModels = modelInteractor.downloadedModels + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val models = combine( + availableModels, + downloadedModels, + downloadingModels + ) { availableModels, downloaded, downloading -> + availableModels.map { availableModel -> + val downloadedModel = downloaded.firstOrNull { it.name == availableModel } + val downloadingModel = downloading.firstOrNull { it == availableModel } + val state = when { + downloadedModel != null -> ModelState.Downloaded + downloadingModel != null -> ModelState.Downloading + else -> ModelState.Available + } + UiModel(availableModel, state) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + fun refreshModelList() { + modelInteractor.refreshAvailableModelsList() + } + + fun downloadModel(modelName: String) { + modelInteractor.getModel(modelName) + } + + fun onDeleteAllModels() { + viewModelScope.launch { + modelInteractor.deleteAllModels() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/settings/ml/model/ModelState.kt b/app/src/main/java/illyan/jay/ui/settings/ml/model/ModelState.kt new file mode 100644 index 00000000..9f612dd2 --- /dev/null +++ b/app/src/main/java/illyan/jay/ui/settings/ml/model/ModelState.kt @@ -0,0 +1,25 @@ +/* + * 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.ui.settings.ml.model + +enum class ModelState { + Available, + Downloading, + Downloaded, +} \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/settings/ml/model/UiModel.kt b/app/src/main/java/illyan/jay/ui/settings/ml/model/UiModel.kt new file mode 100644 index 00000000..98ca044b --- /dev/null +++ b/app/src/main/java/illyan/jay/ui/settings/ml/model/UiModel.kt @@ -0,0 +1,24 @@ +/* + * 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.ui.settings.ml.model + +data class UiModel( + val name: String, + val state: ModelState, +) \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt b/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt index 0b519f88..a9eedeb5 100644 --- a/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt +++ b/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt @@ -18,6 +18,7 @@ package illyan.jay.ui.settings.user +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.animateContentSize @@ -29,11 +30,14 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.CloudOff @@ -71,9 +75,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle @@ -97,6 +103,7 @@ import illyan.jay.ui.components.PreviewAccessibility import illyan.jay.ui.components.SmallCircularProgressIndicator import illyan.jay.ui.components.TooltipElevatedCard import illyan.jay.ui.destinations.DataSettingsDialogScreenDestination +import illyan.jay.ui.destinations.MLSettingsDialogScreenDestination import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.settings.user.model.UiPreferences import illyan.jay.ui.theme.JayTheme @@ -133,7 +140,8 @@ fun UserSettingsDialogScreen( setFreeDriveAutoStart = viewModel::setFreeDriveAutoStart, setAdVisibility = viewModel::setAdVisibility, setDynamicColorEnabled = viewModel::setDynamicColorEnabled, - onDeleteUserData = { destinationsNavigator.navigate(DataSettingsDialogScreenDestination) }, + navigateToDataSettings = { destinationsNavigator.navigate(DataSettingsDialogScreenDestination) }, + navigateToMLSettings = { destinationsNavigator.navigate(MLSettingsDialogScreenDestination) }, ) } @@ -150,8 +158,9 @@ fun UserSettingsDialogContent( setFreeDriveAutoStart: (Boolean) -> Unit = {}, setAdVisibility: (Boolean) -> Unit = {}, setDynamicColorEnabled: (Boolean) -> Unit = {}, - onDeleteUserData: () -> Unit = {}, + navigateToDataSettings: () -> Unit = {}, onThemeChange: (Theme) -> Unit = {}, + navigateToMLSettings: () -> Unit = {}, ) { Crossfade( modifier = modifier.animateContentSize(), @@ -179,6 +188,7 @@ fun UserSettingsDialogContent( setAdVisibility = setAdVisibility, setDynamicColorEnabled = setDynamicColorEnabled, onThemeChange = onThemeChange, + navigateToMLSettings = navigateToMLSettings ) }, buttons = { @@ -186,7 +196,7 @@ fun UserSettingsDialogContent( canSyncPreferences = canSyncPreferences, shouldSyncPreferences = shouldSyncPreferences, onShouldSyncChanged = onShouldSyncChanged, - onDeleteUserData = onDeleteUserData, + navigateToDataSettings = navigateToDataSettings, ) }, containerColor = Color.Transparent, @@ -347,7 +357,7 @@ fun UserSettingsButtons( canSyncPreferences: Boolean = false, shouldSyncPreferences: Boolean = false, onShouldSyncChanged: (Boolean) -> Unit = {}, - onDeleteUserData: () -> Unit = {} + navigateToDataSettings: () -> Unit = {} ) { Row( modifier = modifier.fillMaxWidth(), @@ -356,7 +366,7 @@ fun UserSettingsButtons( ) { MenuButton( text = stringResource(R.string.data_settings), - onClick = onDeleteUserData + onClick = navigateToDataSettings ) SyncPreferencesButton( canSyncPreferences = canSyncPreferences, @@ -539,6 +549,7 @@ fun UserSettingsScreen( setAdVisibility: (Boolean) -> Unit = {}, setDynamicColorEnabled: (Boolean) -> Unit = {}, onThemeChange: (Theme) -> Unit = {}, + navigateToMLSettings: () -> Unit = {}, ) { Crossfade( modifier = modifier, @@ -546,7 +557,20 @@ fun UserSettingsScreen( label = "User Settings Screen" ) { if (it && preferences != null) { - LazyColumn { + val configuration = LocalConfiguration.current + val maxHeight = when (configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> configuration.screenHeightDp + Configuration.ORIENTATION_LANDSCAPE -> configuration.screenWidthDp + else -> configuration.screenHeightDp + } + // TODO: extract 0.5f to a constant per dialog parts + LazyColumn( + modifier = Modifier + .heightIn(max = (maxHeight * 0.55f).dp) + .clip(RoundedCornerShape(12.dp)) + // TODO: disabled buttons blend in too much, find a way to make list pleasing to the eye +// .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + ) { item { BooleanSetting( settingName = stringResource(R.string.analytics), @@ -569,8 +593,11 @@ fun UserSettingsScreen( ) } item { + var isDropdownOpen by rememberSaveable { mutableStateOf(false) } DropdownSetting( settingName = stringResource(R.string.theme), + isDropdownOpen = isDropdownOpen, + toggleDropdown = { isDropdownOpen = !isDropdownOpen }, selectValue = onThemeChange, selectedValue = preferences.theme, values = Theme.values().toList(), @@ -602,6 +629,13 @@ fun UserSettingsScreen( enabled = preferences.canUseDynamicColor, ) } + item { + BasicSetting( + screenName = stringResource(R.string.ml_models), + label = "${preferences.downloadedModels} ${stringResource(R.string.downloaded)}", + onClick = navigateToMLSettings + ) + } } } else { LoadingIndicator() @@ -622,10 +656,10 @@ fun BooleanSetting( ) { SettingItem( modifier = Modifier.fillMaxWidth(), - name = settingName, + settingName = settingName, onClick = { setValue(!value) }, - textStyle = textStyle, - fontWeight = fontWeight, + titleStyle = textStyle, + titleWeight = fontWeight, enabled = enabled, ) { Row( @@ -652,10 +686,14 @@ fun BooleanSetting( } } +// DropdownSetting when opened likes to shift to the start unintentionally. +// Wrap it inside a Row or layour to prevent this. @Composable fun DropdownSetting( selectedValue: T? = null, - values: Iterable, + isDropdownOpen: Boolean = false, + toggleDropdown: () -> Unit = {}, + values: Iterable = emptyList(), getValueName: @Composable (T) -> String = { it.toString() }, getValueLeadingIcon: (T) -> ImageVector? = { null }, getValueTrailingIcon: (T) -> ImageVector? = { null }, @@ -664,13 +702,12 @@ fun DropdownSetting( textStyle: TextStyle = MaterialTheme.typography.labelLarge, fontWeight: FontWeight = FontWeight.Normal, ) { - var isDropdownOpen by remember { mutableStateOf(false) } SettingItem( modifier = Modifier.fillMaxWidth(), - name = settingName, - onClick = { isDropdownOpen = !isDropdownOpen }, - textStyle = textStyle, - fontWeight = fontWeight, + settingName = settingName, + onClick = toggleDropdown, + titleStyle = textStyle, + titleWeight = fontWeight, ) { Row( modifier = Modifier, @@ -692,7 +729,7 @@ fun DropdownSetting( } IconToggleButton( checked = isDropdownOpen, - onCheckedChange = { isDropdownOpen = it } + onCheckedChange = { toggleDropdown() } ) { Icon( imageVector = if (isDropdownOpen) { @@ -706,7 +743,7 @@ fun DropdownSetting( } DropdownMenu( expanded = isDropdownOpen, - onDismissRequest = { isDropdownOpen = false }, + onDismissRequest = toggleDropdown, ) { values.forEach { value -> val leadingIcon = remember { getValueLeadingIcon(value) } @@ -739,7 +776,7 @@ fun DropdownSetting( text = { Text(text = getValueName(value)) }, leadingIcon = (if (leadingIcon != null) leadingComposable else null) as? @Composable (() -> Unit), trailingIcon = (if (trailingIcon != null) trailingComposable else null) as? @Composable (() -> Unit), - onClick = { selectValue(value); isDropdownOpen = false }, + onClick = { selectValue(value); toggleDropdown() }, colors = if (value == selectedValue) { MenuDefaults.itemColors( textColor = MaterialTheme.colorScheme.primary, @@ -755,15 +792,104 @@ fun DropdownSetting( } } +@Composable +fun BasicSetting( + modifier: Modifier = Modifier, + screenName: String, + label: String, + onClick: () -> Unit = {} +) = BasicSetting( + modifier = modifier, + title = screenName, + label = { + TextButton(onClick = onClick) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Icon( + imageVector = Icons.Rounded.ChevronRight, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "" + ) + } + }, + onClick = onClick +) + +@Composable +fun BasicSetting( + modifier: Modifier = Modifier, + title: String, + titleStyle: TextStyle = MaterialTheme.typography.labelLarge, + titleWeight: FontWeight = FontWeight.Normal, + label: @Composable RowScope.() -> Unit = {}, + onClick: () -> Unit = {} +) { + SettingItem( + modifier = modifier, + onClick = onClick, + title = { + Text( + modifier = Modifier.animateContentSize(), + text = title, + style = titleStyle, + fontWeight = titleWeight, + color = MaterialTheme.colorScheme.onSurface + ) + }, + content = label, + ) +} + +@Composable +fun BasicSetting( + modifier: Modifier = Modifier, + title: @Composable RowScope.() -> Unit = {}, + label: @Composable RowScope.() -> Unit = {}, + onClick: () -> Unit = {} +) { + SettingItem( + modifier = modifier, + onClick = onClick, + title = title, + content = label, + ) +} + +@Composable +fun SettingItem( + modifier: Modifier = Modifier, + settingName: String, + titleStyle: TextStyle = MaterialTheme.typography.labelLarge, + titleWeight: FontWeight = FontWeight.Normal, + onClick: () -> Unit = {}, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit = {}, +) = SettingItem( + modifier = modifier, + onClick = onClick, + enabled = enabled, + title = { + Text( + modifier = Modifier.animateContentSize(), + text = settingName, + style = titleStyle, + fontWeight = titleWeight, + color = MaterialTheme.colorScheme.onSurface + ) + }, + content = content +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingItem( modifier: Modifier = Modifier, - name: String, - textStyle: TextStyle = MaterialTheme.typography.labelLarge, - fontWeight: FontWeight = FontWeight.Normal, onClick: () -> Unit = {}, enabled: Boolean = true, + title: @Composable RowScope.() -> Unit = {}, content: @Composable RowScope.() -> Unit = {}, ) { Card( @@ -781,13 +907,7 @@ fun SettingItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = name, - style = textStyle, - fontWeight = fontWeight, - color = MaterialTheme.colorScheme.onSurface - ) + title() Row { content() } } } @@ -835,3 +955,38 @@ fun AnalyticsRequestDialogContentPreview() { } } } + +// DropdownSetting when opened likes to shift to the start unintentionally. +// Wrap it inside a Row or layour to prevent this. +@PreviewAccessibility +@Composable +fun DropdownSettingPreview() { + JayTheme { + JayDialogSurface { + val isDropdownOpen by remember { mutableStateOf(true) } + DropdownSetting( + settingName = stringResource(R.string.theme), + isDropdownOpen = isDropdownOpen, + selectValue = {}, + selectedValue = Theme.entries.random(), + values = Theme.values().toList(), + getValueName = { theme -> + when (theme) { + Theme.System -> stringResource(R.string.system) + Theme.Light -> stringResource(R.string.light) + Theme.Dark -> stringResource(R.string.dark) + Theme.DayNightCycle -> stringResource(R.string.day_night_cycle) + } + }, + getValueLeadingIcon = { theme -> + when (theme) { + Theme.System -> Icons.Rounded.Settings + Theme.Light -> Icons.Rounded.LightMode + Theme.Dark -> Icons.Rounded.DarkMode + Theme.DayNightCycle -> Icons.Rounded.Schedule + } + } + ) + } + } +} diff --git a/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt b/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt index 0698e8b5..e59e6b19 100644 --- a/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import illyan.jay.domain.interactor.AuthInteractor +import illyan.jay.domain.interactor.ModelInteractor import illyan.jay.domain.interactor.SettingsInteractor import illyan.jay.domain.model.Theme import illyan.jay.ui.settings.user.model.UiPreferences @@ -39,6 +40,7 @@ import javax.inject.Inject @HiltViewModel class UserSettingsViewModel @Inject constructor( private val settingsInteractor: SettingsInteractor, + private val modelInteractor: ModelInteractor, authInteractor: AuthInteractor ) : ViewModel() { @@ -47,9 +49,13 @@ class UserSettingsViewModel @Inject constructor( val preferences = combine( settingsInteractor.userPreferences, - settingsInteractor.appSettings - ) { preferences, appSettings -> - val uiPreferences = preferences?.toUiModel(clientUUID = appSettings.clientUUID) + settingsInteractor.appSettings, + modelInteractor.getDownloadedModels() + ) { preferences, appSettings, models -> + val uiPreferences = preferences?.toUiModel( + clientUUID = appSettings.clientUUID, + downloadedModels = models.size, + ) updateAnalyticsRequestDialogVisibility(uiPreferences) uiPreferences }.stateIn(viewModelScope, SharingStarted.Eagerly, null) diff --git a/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt b/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt index f49eed25..d44679a7 100644 --- a/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt +++ b/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt @@ -34,10 +34,12 @@ data class UiPreferences( val lastUpdate: ZonedDateTime = DomainPreferences.Default.lastUpdate, val lastUpdateToAnalytics: ZonedDateTime? = null, val clientUUID: String? = null, + val downloadedModels: Int = 0, ) fun DomainPreferences.toUiModel( - clientUUID: String? = null + clientUUID: String? = null, + downloadedModels: Int = 0, ) = UiPreferences( userUUID = userUUID, analyticsEnabled = analyticsEnabled, @@ -49,4 +51,5 @@ fun DomainPreferences.toUiModel( lastUpdate = lastUpdate, lastUpdateToAnalytics = lastUpdateToAnalytics, clientUUID = clientUUID, + downloadedModels = downloadedModels, ) diff --git a/app/src/main/java/illyan/jay/util/FirebaseRemoteConfigKeys.kt b/app/src/main/java/illyan/jay/util/FirebaseRemoteConfigKeys.kt index c852239e..5e00629b 100644 --- a/app/src/main/java/illyan/jay/util/FirebaseRemoteConfigKeys.kt +++ b/app/src/main/java/illyan/jay/util/FirebaseRemoteConfigKeys.kt @@ -20,4 +20,5 @@ package illyan.jay.util object FirebaseRemoteConfigKeys { const val BannerOnAboutScreenAdUnitIdKey = "banner_on_about_screen" + const val MLAvailableModels = "ml_available_models" } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c773751d..22019021 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ + + ml_available_models + [] + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a31db98b..eba38bcd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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. * @@ -20,6 +20,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.android.test) apply false + alias(libs.plugins.androidx.room) apply false alias(libs.plugins.androidx.navigation.safeargs) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.google.gms.services) apply false @@ -28,10 +29,11 @@ plugins { alias(libs.plugins.firebase.perf) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.serialization) apply false - alias(libs.plugins.jetbrains.kotlin.kapt) apply false alias(libs.plugins.jetbrains.kotlin.parcelize) apply false + alias(libs.plugins.jetbrains.kotlin.jvm) alias(libs.plugins.ksp) apply false alias(libs.plugins.junit5) apply false + alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.sonarqube) } @@ -42,7 +44,3 @@ sonarqube { property("sonar.host.url", "https://sonarcloud.io") } } - -tasks.register("clean", Delete::class) { - delete(rootProject.buildDir) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85564a7f..631f9088 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,64 +1,63 @@ [versions] -compose = "1.6.0-alpha02" -compose-compiler = "1.5.1" -kotlin = "1.9.0" -ksp = "1.9.0-1.0.13" -agp = "8.2.0-alpha15" -accompanist = "0.31.6-rc" -room = "2.5.2" - -jetbrains-kotlinx-serialization = "1.5.1" -jetbrains-kotlinx-collections-immutable = "0.3.5" -jetbrains-kotlinx-coroutines = "1.7.3" - -androidx-core = "1.10.1" -androidx-collection = "1.2.0" -androidx-appcompat = "1.6.1" -androidx-activity = "1.7.2" -androidx-compose-material3 = "1.2.0-alpha04" +compose = "1.6.8" +compose-compiler = "1.5.14" +kotlin = "2.0.0" +ksp = "2.0.0-1.0.23" +agp = "8.5.1" +accompanist = "0.34.0" +room = "2.6.1" + +jetbrains-kotlinx-serialization = "1.7.1" +jetbrains-kotlinx-collections-immutable = "0.3.7" +jetbrains-kotlinx-coroutines = "1.9.0-RC" + +androidx-core = "1.13.1" +androidx-collection = "1.4.2" +androidx-appcompat = "1.7.0" +androidx-activity = "1.9.1" +androidx-compose-material3 = "1.2.1" androidx-constraintlayout-compose = "1.0.1" androidx-profileinstaller = "1.3.1" -androidx-datastore = "1.0.0" -androidx-navigation-safeargs = "2.6.0" -androidx-test-junit = "1.1.5" -androidx-lifecycle = "2.6.1" +androidx-datastore = "1.1.1" +androidx-navigation-safeargs = "2.7.7" +androidx-test-junit = "1.2.1" +androidx-lifecycle = "2.8.4" -hilt = "2.47" -hilt-navigation-compose = "1.0.0" +hilt = "2.51.1" +hilt-navigation-compose = "1.2.0" -zstd-jni = "1.5.5-5" +zstd-jni = "1.5.6-4" solarized = "1.0.8" - hlcaptain-compose-scrollbar = "0.0.3-alpha" -saket-swipe = "1.2.0" +saket-swipe = "1.3.0" timber = "5.0.1" -compose-destinations = "1.9.51" -coil-compose = "2.4.0" +compose-destinations = "1.10.2" +coil-compose = "2.7.0" +apache-commons-math3 = "3.6.1" mapbox-maps = "10.15.0" mapbox-search = "1.0.0-rc.7" mapbox-navigation = "2.15.0-rc.1" google-secrets = "2.0.1" -google-gms-services = "4.3.15" -google-gms-play-services-location = "21.0.1" -google-gms-play-services-auth = "20.6.0" -google-gms-play-services-ads = "22.2.0" -google-maps-utils = "3.4.0" -google-material = "1.9.0" - -firebase-crashlytics = "2.9.8" +google-gms-services = "4.4.2" +google-gms-play-services-location = "21.3.0" +google-gms-play-services-auth = "21.2.0" +google-gms-play-services-ads = "23.2.0" +google-material = "1.12.0" + +firebase-crashlytics = "3.0.2" firebase-perf = "1.4.2" -firebase-bom = "32.2.2" -tensorflow-lite = "2.3.0" +firebase-bom = "33.1.2" +tensorflow-lite = "2.16.1" junit5 = "1.9.3.0" -junit = "5.10.0" +junit = "5.10.3" junit4 = "4.13.2" -mockk = "1.13.5" +mockk = "1.13.12" -desugar-jdk-libs = "2.0.3" +desugar-jdk-libs = "2.0.4" sonarqube = "4.0.0.2929" @@ -103,21 +102,26 @@ google-material = { module = "com.google.android.material:material", version.ref google-gms-play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "google-gms-play-services-location" } google-gms-play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "google-gms-play-services-auth" } google-gms-play-services-ads = { module = "com.google.android.gms:play-services-ads", version.ref = "google-gms-play-services-ads" } -google-maps-utils = { module = "com.google.maps.android:android-maps-utils", version.ref = "google-maps-utils" } -google-maps-utils-ktx = { module = "com.google.maps.android:maps-utils-ktx", version.ref = "google-maps-utils" } +google-maps-utils = { module = "com.google.maps.android:android-maps-utils", version = "3.8.2" } +google-maps-utils-ktx = { module = "com.google.maps.android:maps-utils-ktx", version = "5.1.1" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } -firebase-auth-ktx = { module = "com.google.firebase:firebase-auth-ktx" } -firebase-config-ktx = { module = "com.google.firebase:firebase-config-ktx" } -firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx" } -firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx" } -firebase-firestore-ktx = { module = "com.google.firebase:firebase-firestore-ktx" } -firebase-perf-ktx = { module = "com.google.firebase:firebase-perf-ktx" } -firebase-ml-modeldownloader = { module = "com.google.firebase:firebase-ml-modeldownloader-ktx" } +firebase-auth = { module = "com.google.firebase:firebase-auth" } +firebase-config = { module = "com.google.firebase:firebase-config" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore" } +firebase-perf = { module = "com.google.firebase:firebase-perf" } +firebase-ml-modeldownloader = { module = "com.google.firebase:firebase-ml-modeldownloader" } tensorflow-lite = { module = "org.tensorflow:tensorflow-lite", version.ref = "tensorflow-lite" } hlcaptain-compose-scrollbar = { module = "com.github.HLCaptain:compose-scrollbar", version.ref = "hlcaptain-compose-scrollbar" } saket-swipe = { module = "me.saket.swipe:swipe", version.ref = "saket-swipe" } +coil = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } +zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +solarized = { module = "com.github.phototime:solarized-android", version.ref = "solarized" } +apache-commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "apache-commons-math3" } mapbox-maps = { module = "com.mapbox.maps:android", version.ref = "mapbox-maps" } mapbox-search = { module = "com.mapbox.search:mapbox-search-android-ui", version.ref = "mapbox-search" } @@ -131,16 +135,9 @@ hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" } -timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } - compose-destinations-animations-core = { module = "io.github.raamcosta.compose-destinations:animations-core", version.ref = "compose-destinations" } compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "compose-destinations" } -coil = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } - -zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" } -solarized = { module = "com.github.phototime:solarized-android", version.ref = "solarized" } - junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } @@ -158,9 +155,11 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -jetbrains-kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +androidx-room = { id = "androidx.room", version.ref = "room" } androidx-navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "androidx-navigation-safeargs" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } # From Kotlin 2.0.0-RC2 sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d7266765..080d0926 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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. # @@ -19,6 +19,6 @@ #Thu Jul 27 12:20:23 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists