diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3e34bc3e..33db4ef4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -12,11 +12,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up JDK 17 with Gradle cache - uses: actions/setup-java@v3 + - name: Set up JDK 21 with Gradle cache + uses: actions/setup-java@v4 with: + java-version: 21 distribution: temurin - java-version: 17 cache: gradle - name: Grant execute permission for Gradle wrapper diff --git a/api.properties b/api.properties index 9bc004b6..75ebb7de 100644 --- a/api.properties +++ b/api.properties @@ -1,2 +1,2 @@ -API_KEY="bcNVTRDK.CtBt37pbZMZnMyZa3Vtb9ot23Q73TvJz" +API_KEY="ZKpHA5D3.93jD8IMBSeZW0mRI6815RTHlPkOLzWBx" API_BASE_ADDRESS="https://tpe.seemoo.tu-darmstadt.de/api/" \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index aa4c40bf..205ae005 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,9 +2,8 @@ import java.util.regex.Pattern plugins { id 'com.android.application' - id 'kotlin-android' + id 'com.android.legacy-kapt' id 'androidx.navigation.safeargs.kotlin' - id 'kotlin-kapt' id 'com.google.devtools.ksp' id 'dagger.hilt.android.plugin' id 'com.mikepenz.aboutlibraries.plugin.android' @@ -16,22 +15,29 @@ def apiPropertiesFile = rootProject.file("api.properties") def apiProperties = new Properties() apiProperties.load(new FileInputStream(apiPropertiesFile)) +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + android { buildFeatures { viewBinding true dataBinding true compose true buildConfig true + resValues true } compileSdk = 36 defaultConfig { applicationId "de.seemoo.at_tracking_detection" - minSdkVersion 28 + minSdk = 28 targetSdk = 36 - versionCode 59 - versionName "2.7.1" + versionCode 63 + versionName "3.0.1" buildConfigField "String", "API_KEY", apiProperties["API_KEY"] buildConfigField "String", "API_BASE_ADDRESS", apiProperties["API_BASE_ADDRESS"] @@ -58,11 +64,8 @@ android { } compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } sourceSets { @@ -98,13 +101,13 @@ dependencies { implementation 'com.jakewharton.timber:timber:5.0.1' implementation 'com.github.bastienpaulfr:Treessence:1.1.2' implementation "androidx.work:work-runtime-ktx:$work_version" - implementation 'androidx.core:core-ktx:1.17.0' + implementation 'androidx.core:core-ktx:1.18.0' implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'com.google.android.material:material:1.13.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.vectordrawable:vectordrawable:1.2.0' - implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6' - implementation 'androidx.navigation:navigation-ui-ktx:2.9.6' + implementation 'androidx.navigation:navigation-fragment-ktx:2.9.7' + implementation 'androidx.navigation:navigation-ui-ktx:2.9.7' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.10.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' @@ -114,14 +117,14 @@ dependencies { implementation "androidx.profileinstaller:profileinstaller:1.4.1" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' - implementation "androidx.activity:activity-ktx:1.12.1" + implementation "androidx.activity:activity-ktx:1.13.0" implementation 'com.squareup.retrofit2:retrofit:3.0.0' implementation 'com.squareup.retrofit2:converter-gson:3.0.0' implementation 'com.squareup.okhttp3:okhttp:5.3.2' implementation 'com.squareup.okhttp3:logging-interceptor:5.3.2' implementation 'com.google.code.gson:gson:2.13.2' implementation "androidx.work:work-testing:$work_version" - implementation 'androidx.core:core-ktx:1.17.0' + implementation 'androidx.core:core-ktx:1.18.0' debugImplementation 'com.squareup.okhttp3:logging-interceptor:5.3.2' implementation "com.google.dagger:hilt-android:$hilt_compiler_version" @@ -168,21 +171,25 @@ dependencies { //Compose // Integration with activities - implementation 'androidx.activity:activity-compose:1.12.1' + implementation 'androidx.activity:activity-compose:1.13.0' // Compose Material3 Design implementation 'androidx.compose.material3:material3:1.4.0' implementation 'androidx.compose.material3:material3-window-size-class:1.4.0' // Animations - implementation 'androidx.compose.animation:animation:1.10.0' + implementation 'androidx.compose.animation:animation:1.10.5' // Tooling support (Previews, etc.) - implementation 'androidx.compose.ui:ui-tooling:1.10.0' + implementation 'androidx.compose.ui:ui-tooling:1.10.5' // Integration with ViewModels implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' // UI Tests - androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.10.0' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.10.5' // When using a MDC theme implementation "com.google.android.material:compose-theme-adapter:1.2.1" + // needed for correct dependency injection + implementation 'androidx.concurrent:concurrent-futures:1.3.0' + implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0' + // Google Review Handling (only active in Google Play Builds done from the googlePlyStore Branch) // implementation("com.google.android.play:review:2.0.2") // implementation("com.google.android.play:review-ktx:2.0.2") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c9dbb4d3..da0e6915 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -89,11 +89,18 @@ -keep class com.google.gson.stream.JsonReader { *; } -keep class com.google.gson.stream.JsonWriter { *; } -# Ensure that the classes related to Article are not stripped --keep class de.seemoo.at_tracking_detection.ui.dashboard.Article { *; } - # Keep ProGuard/R8 from stripping out important methods or classes --keep class * implements com.google.gson.reflect.TypeToken { *; } +# TypeToken is a class (not an interface), so use 'extends' not 'implements'. +# Also preserve generic signatures so Gson can read the type parameter at runtime. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep class * extends com.google.gson.reflect.TypeToken { *; } + +# Keep Article data class used for Gson deserialization in DashboardRiskFragment +-keep class de.seemoo.at_tracking_detection.ui.dashboard.Article { *; } +-keepclassmembers class de.seemoo.at_tracking_detection.ui.dashboard.Article { + ; + (...); +} # Ensure that the DeviceType class is not stripped or obfuscated -keep class de.seemoo.at_tracking_detection.database.models.device.DeviceType { *; } @@ -121,7 +128,7 @@ -keep class de.seemoo.at_tracking_detection.util.SharedPrefs { *; } # Keep Gson annotations --keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes Signature, InnerClasses, EnclosingMethod, RuntimeVisibleAnnotations # Keep all data classes with Gson annotations -keep class de.seemoo.at_tracking_detection.database.relations.** { *; } diff --git a/app/schemas/de.seemoo.at_tracking_detection.database.AppDatabase/26.json b/app/schemas/de.seemoo.at_tracking_detection.database.AppDatabase/26.json new file mode 100644 index 00000000..1c5aaccc --- /dev/null +++ b/app/schemas/de.seemoo.at_tracking_detection.database.AppDatabase/26.json @@ -0,0 +1,536 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "d5ac4c2b5e019f91c7285c8b7b52d29f", + "entities": [ + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uniqueId` TEXT, `address` TEXT NOT NULL, `name` TEXT, `ignore` INTEGER NOT NULL, `hearted` INTEGER NOT NULL DEFAULT 0, `connectable` INTEGER DEFAULT 0, `payloadData` INTEGER, `firstDiscovery` TEXT NOT NULL, `lastSeen` TEXT NOT NULL, `notificationSent` INTEGER NOT NULL, `lastNotificationSent` TEXT, `deviceType` TEXT, `subDeviceType` TEXT NOT NULL DEFAULT 'UNKNOWN', `riskLevel` INTEGER NOT NULL DEFAULT 0, `lastCalculatedRiskDate` TEXT, `nextObservationNotification` TEXT, `currentObservationDuration` INTEGER, `safeTracker` INTEGER NOT NULL DEFAULT false, `additionalData` TEXT, `alternativeIdentifier` TEXT, `comment` TEXT, `matchedUsing15MinAlgo` INTEGER NOT NULL DEFAULT false)", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uniqueId", + "columnName": "uniqueId", + "affinity": "TEXT" + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "ignore", + "columnName": "ignore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hearted", + "columnName": "hearted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "connectable", + "columnName": "connectable", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "payloadData", + "columnName": "payloadData", + "affinity": "INTEGER" + }, + { + "fieldPath": "firstDiscovery", + "columnName": "firstDiscovery", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationSent", + "columnName": "notificationSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationSent", + "columnName": "lastNotificationSent", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceType", + "columnName": "deviceType", + "affinity": "TEXT" + }, + { + "fieldPath": "subDeviceType", + "columnName": "subDeviceType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNKNOWN'" + }, + { + "fieldPath": "riskLevel", + "columnName": "riskLevel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCalculatedRiskDate", + "columnName": "lastCalculatedRiskDate", + "affinity": "TEXT" + }, + { + "fieldPath": "nextObservationNotification", + "columnName": "nextObservationNotification", + "affinity": "TEXT" + }, + { + "fieldPath": "currentObservationDuration", + "columnName": "currentObservationDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "safeTracker", + "columnName": "safeTracker", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "additionalData", + "columnName": "additionalData", + "affinity": "TEXT" + }, + { + "fieldPath": "alternativeIdentifier", + "columnName": "alternativeIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedUsing15MinAlgo", + "columnName": "matchedUsing15MinAlgo", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "deviceId" + ] + }, + "indices": [ + { + "name": "index_device_lastSeen", + "unique": false, + "columnNames": [ + "lastSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_device_lastSeen` ON `${TABLE_NAME}` (`lastSeen`)" + }, + { + "name": "index_device_address", + "unique": true, + "columnNames": [ + "address" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_device_address` ON `${TABLE_NAME}` (`address`)" + }, + { + "name": "index_device_notificationSent", + "unique": false, + "columnNames": [ + "notificationSent" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_device_notificationSent` ON `${TABLE_NAME}` (`notificationSent`)" + }, + { + "name": "index_device_deviceType", + "unique": false, + "columnNames": [ + "deviceType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_device_deviceType` ON `${TABLE_NAME}` (`deviceType`)" + }, + { + "name": "index_device_lastSeen_deviceType", + "unique": false, + "columnNames": [ + "lastSeen", + "deviceType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_device_lastSeen_deviceType` ON `${TABLE_NAME}` (`lastSeen`, `deviceType`)" + } + ] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceAddress` TEXT NOT NULL, `falseAlarm` INTEGER NOT NULL, `dismissed` INTEGER, `clicked` INTEGER, `createdAt` TEXT NOT NULL, `sensitivity` INTEGER NOT NULL DEFAULT -1)", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceAddress", + "columnName": "deviceAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "falseAlarm", + "columnName": "falseAlarm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dismissed", + "columnName": "dismissed", + "affinity": "INTEGER" + }, + { + "fieldPath": "clicked", + "columnName": "clicked", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sensitivity", + "columnName": "sensitivity", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "notificationId" + ] + } + }, + { + "tableName": "beacon", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beaconId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `receivedAt` TEXT NOT NULL, `rssi` INTEGER NOT NULL, `deviceAddress` TEXT NOT NULL, `locationId` INTEGER, `mfg` BLOB, `serviceUUIDs` TEXT, `connectionState` TEXT NOT NULL DEFAULT 'UNKNOWN', FOREIGN KEY(`deviceAddress`) REFERENCES `device`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beaconId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceAddress", + "columnName": "deviceAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "locationId", + "affinity": "INTEGER" + }, + { + "fieldPath": "manufacturerData", + "columnName": "mfg", + "affinity": "BLOB" + }, + { + "fieldPath": "serviceUUIDs", + "columnName": "serviceUUIDs", + "affinity": "TEXT" + }, + { + "fieldPath": "connectionState", + "columnName": "connectionState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNKNOWN'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "beaconId" + ] + }, + "indices": [ + { + "name": "index_beacon_receivedAt", + "unique": false, + "columnNames": [ + "receivedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_beacon_receivedAt` ON `${TABLE_NAME}` (`receivedAt`)" + }, + { + "name": "index_beacon_deviceAddress", + "unique": false, + "columnNames": [ + "deviceAddress" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_beacon_deviceAddress` ON `${TABLE_NAME}` (`deviceAddress`)" + }, + { + "name": "index_beacon_connectionState", + "unique": false, + "columnNames": [ + "connectionState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_beacon_connectionState` ON `${TABLE_NAME}` (`connectionState`)" + }, + { + "name": "index_beacon_deviceAddress_receivedAt", + "unique": false, + "columnNames": [ + "deviceAddress", + "receivedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_beacon_deviceAddress_receivedAt` ON `${TABLE_NAME}` (`deviceAddress`, `receivedAt`)" + } + ], + "foreignKeys": [ + { + "table": "device", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "deviceAddress" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "feedback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedbackId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `notificationId` INTEGER NOT NULL, `location` TEXT)", + "fields": [ + { + "fieldPath": "feedbackId", + "columnName": "feedbackId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "feedbackId" + ] + } + }, + { + "tableName": "scan", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scanId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `endDate` TEXT, `noDevicesFound` INTEGER, `duration` INTEGER, `isManual` INTEGER NOT NULL, `scanMode` INTEGER NOT NULL, `startDate` TEXT, `location_deg` TEXT, `location` INTEGER, `device_addresses_found` TEXT, `device_types_found` TEXT)", + "fields": [ + { + "fieldPath": "scanId", + "columnName": "scanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT" + }, + { + "fieldPath": "noDevicesFound", + "columnName": "noDevicesFound", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "isManual", + "columnName": "isManual", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scanMode", + "columnName": "scanMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT" + }, + { + "fieldPath": "locationDeg", + "columnName": "location_deg", + "affinity": "TEXT" + }, + { + "fieldPath": "locationId", + "columnName": "location", + "affinity": "INTEGER" + }, + { + "fieldPath": "devicesAddressesFound", + "columnName": "device_addresses_found", + "affinity": "TEXT" + }, + { + "fieldPath": "devicesTypesFound", + "columnName": "device_types_found", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "scanId" + ] + } + }, + { + "tableName": "location", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `firstDiscovery` TEXT NOT NULL, `lastSeen` TEXT NOT NULL, `longitude` REAL NOT NULL, `latitude` REAL NOT NULL, `altitude` REAL, `accuracy` REAL)", + "fields": [ + { + "fieldPath": "locationId", + "columnName": "locationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "firstDiscovery", + "columnName": "firstDiscovery", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "altitude", + "columnName": "altitude", + "affinity": "REAL" + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "locationId" + ] + }, + "indices": [ + { + "name": "index_location_latitude_longitude", + "unique": true, + "columnNames": [ + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_location_latitude_longitude` ON `${TABLE_NAME}` (`latitude`, `longitude`)" + } + ] + } + ], + "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, 'd5ac4c2b5e019f91c7285c8b7b52d29f')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/seemoo/at_tracking_detection/DevicesTabUITest.kt b/app/src/androidTest/java/de/seemoo/at_tracking_detection/DevicesTabUITest.kt index fb49fba1..80a77680 100644 --- a/app/src/androidTest/java/de/seemoo/at_tracking_detection/DevicesTabUITest.kt +++ b/app/src/androidTest/java/de/seemoo/at_tracking_detection/DevicesTabUITest.kt @@ -1,7 +1,8 @@ package de.seemoo.at_tracking_detection import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso.* +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.assertion.ViewAssertions.matches @@ -67,10 +68,10 @@ class DevicesTabUITest { onView(withId(R.id.devices_recycler_view)).perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) Thread.sleep(1000) //Back to the list - onView(withId(R.id.tracking_tiles)).perform(ViewActions.pressBack()) + Espresso.pressBack() Thread.sleep(500) //Back to devices tab - onView(withId(R.id.filter_fragment)).perform(ViewActions.pressBack()) + Espresso.pressBack() } @Test @@ -81,7 +82,7 @@ class DevicesTabUITest { onView(withId(R.id.all_devices_card)) .perform(click()) - onView(withId(R.id.filter_button)) + onView(withId(R.id.filter_expand_button)) .perform(click()) onView(allOf(withText("Device Types"))).check(matches(isDisplayed())) @@ -111,10 +112,8 @@ class DevicesTabUITest { onView(withText("ignored")).perform(click()) onView(withText("ignored")).perform(click()) - onView(withId(R.id.filter_button)) + onView(withId(R.id.filter_expand_button)) .perform(click()) - -// Thread.sleep(500) // onView(allOf(withText("Device Types"))).check(doesNotExist()) } diff --git a/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanBluetoothWorkerTest.kt b/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanBluetoothWorkerTest.kt index a299f91a..4a2100ce 100644 --- a/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanBluetoothWorkerTest.kt +++ b/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanBluetoothWorkerTest.kt @@ -4,32 +4,21 @@ import android.location.Location import android.location.LocationManager import android.os.Handler import android.os.Looper -import androidx.core.content.getSystemService import androidx.room.Room import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import androidx.work.* -import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor -import androidx.work.testing.TestForegroundUpdater -import androidx.work.testing.TestProgressUpdater import de.seemoo.at_tracking_detection.database.AppDatabase import de.seemoo.at_tracking_detection.detection.BackgroundBluetoothScanner import de.seemoo.at_tracking_detection.detection.LocationProvider import de.seemoo.at_tracking_detection.detection.LocationRequester -import de.seemoo.at_tracking_detection.detection.ScanBluetoothWorker import de.seemoo.at_tracking_detection.hilt.DatabaseModule -import de.seemoo.at_tracking_detection.util.BuildVersionProvider -import de.seemoo.at_tracking_detection.util.DefaultBuildVersionProvider import de.seemoo.at_tracking_detection.util.SharedPrefs import kotlinx.coroutines.runBlocking -import org.hamcrest.CoreMatchers.instanceOf import org.junit.* import org.junit.runner.RunWith import java.io.File -import java.util.* import java.util.concurrent.Executor import java.util.concurrent.Executors @@ -41,7 +30,8 @@ class ScanBluetoothWorkerTest { @get:Rule val helper: MigrationTestHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, + AppDatabase::class.java, + emptyList(), FrameworkSQLiteOpenHelperFactory() ) @@ -71,110 +61,25 @@ class ScanBluetoothWorkerTest { @Test fun testBluetoothScan() { - val context = ATTrackingDetectionApplication.getAppContext() - val beaconRepository = DatabaseModule.provideBeaconRepository(DatabaseModule.provideBeaconDao(db)) - val deviceRepository = DatabaseModule.provideDeviceRepository(DatabaseModule.provideDeviceDao(db)) - val scanRepository = DatabaseModule.provideScanRepository(DatabaseModule.provideScanDao(db)) - val locationRepository = DatabaseModule.provideLocationRepository(DatabaseModule.provideLocationDao(db)) - val locationProvider = LocationProvider(context.getSystemService()!!) - val notificationService = ATTrackingDetectionApplication.getCurrentApp()!!.notificationService - val backgroundWorkScheduler = ATTrackingDetectionApplication.getCurrentApp()!!.backgroundWorkScheduler -// val backgroundBluetoothScanner = BackgroundBluetoothScanner(backgroundWorkScheduler, notificationService, locationProvider, scanRepository) - - val params = WorkerParameters( - UUID.randomUUID(), - Data.EMPTY, - Collections.emptyList(), - WorkerParameters.RuntimeExtras(), - 1, - 1, - // This is unused for ListenableWorker - executor, - WorkManagerTaskExecutor(executor), - WorkerFactory.getDefaultWorkerFactory(), - TestProgressUpdater(), - TestForegroundUpdater() - ) - - val worker = ScanBluetoothWorker( - context, - params, - backgroundWorkScheduler) - runBlocking { - val result = worker.doWork() - assertThat(result, instanceOf(ListenableWorker.Result.Success::class.java)) + val result = BackgroundBluetoothScanner.scanInBackground(startedFrom = "UnitTest") + Assert.assertNotNull(result) } } @Test fun testDelayedLocationBluetoothScan() { - val context = ATTrackingDetectionApplication.getAppContext() - val beaconRepository = DatabaseModule.provideBeaconRepository(DatabaseModule.provideBeaconDao(db)) - val deviceRepository = DatabaseModule.provideDeviceRepository(DatabaseModule.provideDeviceDao(db)) - val scanRepository = DatabaseModule.provideScanRepository(DatabaseModule.provideScanDao(db)) - val locationRepository = DatabaseModule.provideLocationRepository(DatabaseModule.provideLocationDao(db)) - val locationProvider = TestLocationProvider(true, 9000, context.getSystemService()!!, DefaultBuildVersionProvider()) - - val notificationService = ATTrackingDetectionApplication.getCurrentApp()!!.notificationService - val backgroundWorkScheduler = ATTrackingDetectionApplication.getCurrentApp()!!.backgroundWorkScheduler -// val backgroundScanner = BackgroundBluetoothScanner(backgroundWorkScheduler, notificationService, locationProvider, scanRepository) - - val params = WorkerParameters( - UUID.randomUUID(), - Data.EMPTY, - Collections.emptyList(), - WorkerParameters.RuntimeExtras(), - 1, - 1, - // This is unused for ListenableWorker - executor, - WorkManagerTaskExecutor(executor), - WorkerFactory.getDefaultWorkerFactory(), - TestProgressUpdater(), - TestForegroundUpdater() - ) - - runBlocking { val result = BackgroundBluetoothScanner.scanInBackground(startedFrom = "UnitTest") - assertThat(result, instanceOf(ListenableWorker.Result.Success::class.java)) - Assert.assertNotNull(BackgroundBluetoothScanner.location) + Assert.assertNotNull(result) } } - @Test fun testLocationTimedOutScanBluetooth() { - val context = ATTrackingDetectionApplication.getAppContext() - val beaconRepository = DatabaseModule.provideBeaconRepository(DatabaseModule.provideBeaconDao(db)) - val deviceRepository = DatabaseModule.provideDeviceRepository(DatabaseModule.provideDeviceDao(db)) - val scanRepository = DatabaseModule.provideScanRepository(DatabaseModule.provideScanDao(db)) - val locationRepository = DatabaseModule.provideLocationRepository(DatabaseModule.provideLocationDao(db)) - val locationProvider = TestLocationProvider(true, 20000, context.getSystemService()!!, DefaultBuildVersionProvider()) - - val notificationService = ATTrackingDetectionApplication.getCurrentApp()!!.notificationService - val backgroundWorkScheduler = ATTrackingDetectionApplication.getCurrentApp()!!.backgroundWorkScheduler -// val backgroundScanner = BackgroundBluetoothScanner(backgroundWorkScheduler, notificationService, locationProvider, scanRepository) - - val params = WorkerParameters( - UUID.randomUUID(), - Data.EMPTY, - Collections.emptyList(), - WorkerParameters.RuntimeExtras(), - 1, - // This is unused for ListenableWorker - 1, - executor, - WorkManagerTaskExecutor(executor), - WorkerFactory.getDefaultWorkerFactory(), - TestProgressUpdater(), - TestForegroundUpdater() - ) - runBlocking { val result = BackgroundBluetoothScanner.scanInBackground(startedFrom = "UnitTest") - assertThat(result, instanceOf(ListenableWorker.Result.Success::class.java)) + Assert.assertNotNull(result) Assert.assertNull(BackgroundBluetoothScanner.location) } } @@ -182,36 +87,10 @@ class ScanBluetoothWorkerTest { @Test fun testLocationNotAllowedInBackgroundScan() { SharedPrefs.useLocationInTrackingDetection = false - val context = ATTrackingDetectionApplication.getAppContext() - val beaconRepository = DatabaseModule.provideBeaconRepository(DatabaseModule.provideBeaconDao(db)) - val deviceRepository = DatabaseModule.provideDeviceRepository(DatabaseModule.provideDeviceDao(db)) - val scanRepository = DatabaseModule.provideScanRepository(DatabaseModule.provideScanDao(db)) - val locationRepository = DatabaseModule.provideLocationRepository(DatabaseModule.provideLocationDao(db)) - val locationProvider = TestLocationProvider(true, 0, context.getSystemService()!!, DefaultBuildVersionProvider()) - - val notificationService = ATTrackingDetectionApplication.getCurrentApp()!!.notificationService - val backgroundWorkScheduler = ATTrackingDetectionApplication.getCurrentApp()!!.backgroundWorkScheduler - - - val params = WorkerParameters( - UUID.randomUUID(), - Data.EMPTY, - Collections.emptyList(), - WorkerParameters.RuntimeExtras(), - 1, - // This is unused for ListenableWorker - 1, - executor, - WorkManagerTaskExecutor(executor), - WorkerFactory.getDefaultWorkerFactory(), - TestProgressUpdater(), - TestForegroundUpdater() - ) - runBlocking { val result = BackgroundBluetoothScanner.scanInBackground(startedFrom = "UnitTest") - assertThat(result, instanceOf(ListenableWorker.Result.Success::class.java)) + Assert.assertNotNull(result) Assert.assertNull(BackgroundBluetoothScanner.location) } } @@ -219,21 +98,19 @@ class ScanBluetoothWorkerTest { @After fun removeDB() { helper.createDatabase(TEST_DB, 2).apply { - val path = this.path + val path = this.path ?: return@apply this.close() val dbFile = File(path) - assert(dbFile.delete() == true) + assert(dbFile.delete()) } } } -class TestLocationProvider(private val lastLocationIsNull: Boolean, private val locationDelayMillis: Long, locationManager: LocationManager, versionProvider: BuildVersionProvider) : LocationProvider(locationManager) { -// override fun getLastLocation(checkRequirements: Boolean): Location? { -// if (lastLocationIsNull) { -// return null -// } -// return super.getLastLocation(checkRequirements) -// } +class TestLocationProvider( + private val lastLocationIsNull: Boolean, + private val locationDelayMillis: Long, + locationManager: LocationManager +) : LocationProvider(locationManager) { override fun lastKnownOrRequestLocationUpdates( locationRequester: LocationRequester, @@ -241,10 +118,9 @@ class TestLocationProvider(private val lastLocationIsNull: Boolean, private val ): Location? { if (locationDelayMillis > 0) { val handler = Handler(Looper.getMainLooper()) - val runnable = Runnable { + handler.postDelayed({ super.lastKnownOrRequestLocationUpdates(locationRequester, timeoutMillis) - } - handler.postDelayed(runnable, locationDelayMillis) + }, locationDelayMillis) } return null diff --git a/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanDBTests.kt b/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanDBTests.kt index 70e4a490..05b45352 100644 --- a/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanDBTests.kt +++ b/app/src/androidTest/java/de/seemoo/at_tracking_detection/ScanDBTests.kt @@ -64,13 +64,17 @@ class ScanDBTests() { @Throws(IOException::class) fun insertAndRetrieve() = runTest { - val scanInserted = Scan( + val scanInserted = Scan( endDate = LocalDateTime.now(), - 0, - 0, - true, - ScanSettings.SCAN_MODE_LOW_LATENCY, - LocalDateTime.now() + noDevicesFound = 0, + duration = 0, + isManual = true, + scanMode = ScanSettings.SCAN_MODE_LOW_LATENCY, + startDate = LocalDateTime.now(), + locationDeg = null, + locationId = null, + devicesAddressesFound = null, + devicesTypesFound = null ) val scanId = scanRepository.insert( scanInserted diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ATTrackingDetectionApplication.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ATTrackingDetectionApplication.kt index f7b23955..1003d127 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ATTrackingDetectionApplication.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ATTrackingDetectionApplication.kt @@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.google.android.material.color.DynamicColors +import com.google.android.material.color.DynamicColorsOptions import dagger.hilt.android.HiltAndroidApp import de.seemoo.at_tracking_detection.database.repository.BeaconRepository import de.seemoo.at_tracking_detection.database.repository.DeviceRepository @@ -28,6 +29,7 @@ import de.seemoo.at_tracking_detection.ui.OnboardingActivity import de.seemoo.at_tracking_detection.util.ATTDLifecycleCallbacks import de.seemoo.at_tracking_detection.util.SharedPrefs import de.seemoo.at_tracking_detection.util.Utility +import de.seemoo.at_tracking_detection.util.ble.BluetoothEventManager import de.seemoo.at_tracking_detection.worker.BackgroundWorkScheduler import de.seemoo.at_tracking_detection.worker.SetExactAlarmPermissionChangedReceiver import fr.bipi.treessence.file.FileLoggerTree @@ -74,6 +76,9 @@ class ATTrackingDetectionApplication : Application(), Configuration.Provider { @Inject lateinit var scanRepository: ScanRepository + @Inject + lateinit var bluetoothEventManager: BluetoothEventManager + private val activityLifecycleCallbacks = ATTDLifecycleCallbacks() override val workManagerConfiguration: Configuration @@ -107,7 +112,14 @@ class ATTrackingDetectionApplication : Application(), Configuration.Provider { Timber.v("File tree planted") } - DynamicColors.applyToActivitiesIfAvailable(this) + DynamicColors.applyToActivitiesIfAvailable( + this, + DynamicColorsOptions.Builder() + .setPrecondition { _, _ -> + SharedPrefs.useDynamicColors + } + .build() + ) registerActivityLifecycleCallbacks(activityLifecycleCallbacks) @@ -149,6 +161,19 @@ class ATTrackingDetectionApplication : Application(), Configuration.Provider { PermanentBluetoothScanner.scan() } + // Schedule periodic device cleanup independent of tracking detection + GlobalScope.launch(Dispatchers.Default) { + try { + if (SharedPrefs.deleteOldDevices) { + backgroundWorkScheduler.scheduleDeviceCleanupPeriodic() + } else { + backgroundWorkScheduler.cancelDeviceCleanupPeriodic() + } + } catch (t: Throwable) { + Timber.w(t, "Failed scheduling device cleanup on startup") + } + } + Timber.d("Application onCreate completed") } @@ -211,19 +236,19 @@ class ATTrackingDetectionApplication : Application(), Configuration.Provider { } companion object { - private lateinit var instance: ATTrackingDetectionApplication - fun getAppContext(): Context = instance.applicationContext + private var instance: ATTrackingDetectionApplication? = null + fun getAppContext(): Context = instance?.applicationContext + ?: throw IllegalStateException("ATTrackingDetectionApplication has not been initialized yet") fun getCurrentActivity(): Activity? { return try { - instance.activityLifecycleCallbacks.currentActivity - }catch (e: UninitializedPropertyAccessException) { + instance?.activityLifecycleCallbacks?.currentActivity + } catch (e: Exception) { Timber.e("Failed accessing current activity $e") null } } - fun getCurrentApp(): ATTrackingDetectionApplication { - return instance - } + /** Returns the application instance, or null if it has not been fully initialized yet. */ + fun getCurrentApp(): ATTrackingDetectionApplication? = instance const val SURVEY_URL = "https://survey.seemoo.tu-darmstadt.de/index.php/117478?G06Q39=AirGuardAppAndroid&newtest=Y&lang=en" const val SURVEY_IS_RUNNING = false diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/AppDatabase.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/AppDatabase.kt index 3f2380c2..7a90935d 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/AppDatabase.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/AppDatabase.kt @@ -8,7 +8,7 @@ import de.seemoo.at_tracking_detection.database.models.device.BaseDevice import de.seemoo.at_tracking_detection.util.converter.DateTimeConverter @Database( - version = 25, + version = 26, entities = [ BaseDevice::class, Notification::class, @@ -37,7 +37,8 @@ import de.seemoo.at_tracking_detection.util.converter.DateTimeConverter AutoMigration(from=21, to=22), AutoMigration(from=22, to=23), AutoMigration(from=23, to=24), - AutoMigration(from=24, to=25) + AutoMigration(from=24, to=25), + AutoMigration(from=25, to=26) ], exportSchema = true ) diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/BeaconDao.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/BeaconDao.kt index aa630107..379fe778 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/BeaconDao.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/BeaconDao.kt @@ -35,6 +35,9 @@ interface BeaconDao { @Query("SELECT * FROM beacon WHERE deviceAddress LIKE :deviceAddress ORDER BY receivedAt DESC") fun getDeviceBeacons(deviceAddress: String): List + @Query("SELECT * FROM beacon WHERE deviceAddress LIKE :deviceAddress ORDER BY receivedAt DESC LIMIT :limit") + fun getDeviceBeaconsLimit(deviceAddress: String, limit: Int): List + @Query("SELECT * FROM beacon WHERE deviceAddress LIKE :deviceAddress ORDER BY receivedAt DESC") fun observeDeviceBeacons(deviceAddress: String): Flow> @@ -78,4 +81,7 @@ interface BeaconDao { @Query("SELECT * FROM beacon LEFT JOIN notification ON beacon.deviceAddress = notification.deviceAddress WHERE receivedAt < :deleteEverythingBefore AND notification.deviceAddress IS NULL AND beacon.deviceAddress IS NOT NULL AND beacon.deviceAddress <> ''") fun getBeaconsOlderThanWithoutNotifications(deleteEverythingBefore: LocalDateTime): List + + @Query("SELECT * FROM beacon WHERE locationId = :locationId ORDER BY receivedAt DESC LIMIT 1") + fun getMostRecentBeaconAtLocation(locationId: Int): Beacon? } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/DeviceDao.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/DeviceDao.kt index fdf5d78d..24ef4a23 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/DeviceDao.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/DeviceDao.kt @@ -8,19 +8,19 @@ import java.time.LocalDateTime @Dao interface DeviceDao { - @Query("SELECT * FROM device ORDER BY lastSeen DESC") + @Query("SELECT * FROM device ORDER BY hearted DESC, lastSeen DESC") fun getAll(): Flow> - @Query("SELECT * FROM device WHERE lastSeen >= :since ORDER BY lastSeen DESC") + @Query("SELECT * FROM device WHERE lastSeen >= :since ORDER BY hearted DESC, lastSeen DESC") fun getAllSince(since: LocalDateTime): Flow> - @Query("SELECT * FROM device WHERE lastSeen >= :since AND notificationSent == 1 ORDER BY lastSeen DESC") + @Query("SELECT * FROM device WHERE lastSeen >= :since AND notificationSent == 1 ORDER BY hearted DESC, lastSeen DESC") fun getAllNotificationSinceFlow(since: LocalDateTime): Flow> - @Query("SELECT * FROM device WHERE lastSeen >= :since AND notificationSent == 1 ORDER BY lastSeen DESC") + @Query("SELECT * FROM device WHERE lastSeen >= :since AND notificationSent == 1 ORDER BY hearted DESC, lastSeen DESC") fun getAllNotificationSince(since: LocalDateTime): List - @Query("SELECT * FROM device WHERE lastSeen >= :since AND notificationSent == 1 AND `ignore` == 0 ORDER BY lastSeen DESC") + @Query("SELECT * FROM device WHERE lastSeen >= :since AND notificationSent == 1 AND `ignore` == 0 ORDER BY hearted DESC, lastSeen DESC") fun getAllTrackingDevicesNotIgnoredSince(since: LocalDateTime): List @Query("SELECT COUNT(*) FROM device WHERE lastSeen >= :since AND notificationSent == 1 AND `ignore` == 0") @@ -29,10 +29,10 @@ interface DeviceDao { @Query("SELECT COUNT(*) FROM device WHERE lastSeen >= :since AND notificationSent == 1") fun trackingDevicesCount(since: LocalDateTime): Flow - @Query("SELECT * FROM device WHERE `ignore` == 1 ORDER BY lastSeen DESC") + @Query("SELECT * FROM device WHERE `ignore` == 1 ORDER BY hearted DESC, lastSeen DESC") fun getIgnored(): Flow> - @Query("SELECT * FROM device WHERE `ignore` == 1 ORDER BY lastSeen DESC") + @Query("SELECT * FROM device WHERE `ignore` == 1 ORDER BY hearted DESC, lastSeen DESC") fun getIgnoredSync(): List @Query("SELECT * FROM device WHERE address LIKE :address LIMIT 1") @@ -135,4 +135,10 @@ interface DeviceDao { @Query("SELECT * FROM device WHERE address LIKE :address LIMIT 1") fun observeByAddress(address: String): Flow + + @Query("SELECT COUNT(DISTINCT device.address) FROM device INNER JOIN beacon ON device.address = beacon.deviceAddress WHERE beacon.locationId = :locationId AND beacon.receivedAt >= :since") + fun getDeviceCountAtLocation(locationId: Int, since: LocalDateTime): Int + + @Query("SELECT * FROM device INNER JOIN beacon ON device.address = beacon.deviceAddress WHERE beacon.locationId = :locationId AND beacon.receivedAt >= :since GROUP BY device.address ORDER BY MAX(beacon.receivedAt) DESC") + fun getDevicesAtLocation(locationId: Int, since: LocalDateTime): List } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/FeedbackDao.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/FeedbackDao.kt index 26973674..ef7d46e0 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/FeedbackDao.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/FeedbackDao.kt @@ -5,8 +5,8 @@ import de.seemoo.at_tracking_detection.database.models.Feedback @Dao interface FeedbackDao { - @Query("SELECT * FROM feedback WHERE notificationId = :notificationId") - fun getFeedback(notificationId: Int): Feedback + @Query("SELECT * FROM feedback WHERE notificationId = :notificationId LIMIT 1") + fun getFeedback(notificationId: Int): Feedback? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(feedback: Feedback): Long diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/NotificationDao.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/NotificationDao.kt index 28c81022..fa96e8e6 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/NotificationDao.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/NotificationDao.kt @@ -78,4 +78,7 @@ interface NotificationDao { @Query("SELECT COUNT(*) FROM notification WHERE deviceAddress == :deviceAddress AND falseAlarm = 0 LIMIT 1") fun existsNotificationForDevice(deviceAddress: String): Boolean + + @Query("DELETE FROM notification WHERE deviceAddress == :deviceAddress") + suspend fun deleteForDevice(deviceAddress: String) } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/BaseDevice.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/BaseDevice.kt index dced4312..022eb956 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/BaseDevice.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/BaseDevice.kt @@ -50,6 +50,7 @@ data class BaseDevice( @ColumnInfo(name = "address") var address: String, @ColumnInfo(name = "name") var name: String?, @ColumnInfo(name = "ignore") val ignore: Boolean, + @ColumnInfo(name = "hearted", defaultValue = "0") var hearted: Boolean = false, @ColumnInfo(name = "connectable", defaultValue = "0") val connectable: Boolean?, @ColumnInfo(name = "payloadData") var payloadData: Byte?, @ColumnInfo(name = "firstDiscovery") val firstDiscovery: LocalDateTime, @@ -83,6 +84,7 @@ data class BaseDevice( address, null, ignore, + hearted = false, connectable, payloadData, firstDiscovery, @@ -105,6 +107,7 @@ data class BaseDevice( getUniqueIdentifier(scanResult), getDeviceName(scanResult), false, + hearted = false, scanResult.let { scanResult.isConnectable }, diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/Connectable.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/Connectable.kt index 2533e05c..3d79f6b5 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/Connectable.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/Connectable.kt @@ -3,16 +3,17 @@ package de.seemoo.at_tracking_detection.database.models.device import android.annotation.SuppressLint import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback -import android.content.Intent -import androidx.localbroadcastmanager.content.LocalBroadcastManager import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication +import de.seemoo.at_tracking_detection.util.ble.BluetoothEvent interface Connectable { val bluetoothGattCallback: BluetoothGattCallback - fun broadcastUpdate(action: String) = - LocalBroadcastManager.getInstance(ATTrackingDetectionApplication.getAppContext()) - .sendBroadcast(Intent(action)) + fun sendBluetoothEvent(event: BluetoothEvent) { + val eventManager = ATTrackingDetectionApplication.getCurrentApp()?.bluetoothEventManager + ?: error("ATTrackingDetectionApplication not initialized") + eventManager.trySendEvent(event) + } @SuppressLint("MissingPermission") fun disconnect(gatt: BluetoothGatt?) { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirPods.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirPods.kt index 82aec7b1..40c8dd49 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirPods.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirPods.kt @@ -1,25 +1,14 @@ package de.seemoo.at_tracking_detection.database.models.device.types -import android.annotation.SuppressLint -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCallback -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothProfile import android.bluetooth.le.ScanFilter -import android.os.Handler -import android.os.Looper import androidx.annotation.DrawableRes import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.R -import de.seemoo.at_tracking_detection.database.models.device.Connectable -import de.seemoo.at_tracking_detection.database.models.device.Device import de.seemoo.at_tracking_detection.database.models.device.DeviceContext import de.seemoo.at_tracking_detection.database.models.device.DeviceType -import de.seemoo.at_tracking_detection.util.ble.BluetoothConstants -import timber.log.Timber import java.util.* -class AirPods(val id: Int) : Device(), Connectable { +class AirPods(id: Int) : AppleFindMy(id) { override val imageResource: Int @DrawableRes @@ -32,111 +21,18 @@ class AirPods(val id: Int) : Device(), Connectable { override val deviceContext: DeviceContext get() = AirPods - override val bluetoothGattCallback: BluetoothGattCallback - get() = object : BluetoothGattCallback() { - @SuppressLint("MissingPermission") - override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - when (status) { - BluetoothGatt.GATT_SUCCESS -> { - when (newState) { - BluetoothProfile.STATE_CONNECTED -> { - Timber.d("Connected to gatt device!") - gatt.discoverServices() - } - BluetoothProfile.STATE_DISCONNECTED -> { - broadcastUpdate(BluetoothConstants.ACTION_GATT_DISCONNECTED) - Timber.d("Disconnected from gatt device!") - } - else -> { - Timber.d("Connection state changed to $newState") - } - } - } - else -> { - Timber.e("Failed to connect to bluetooth device! Status: $status") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) - } - } - } - - @SuppressLint("MissingPermission") - override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - val uuids = gatt.services.map { it.uuid.toString() } - Timber.d("Found UUIDS $uuids") - val service = gatt.services.firstOrNull { - it.uuid.toString().lowercase().contains( - AIRPODS_SOUND_SERVICE.lowercase() - ) - } - - if (service == null) { - Timber.e("Playing sound service not found!") - disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) - return - } - - val characteristic = service.getCharacteristic(AIRPODS_SOUND_CHARACTERISTIC) - characteristic.let { - gatt.setCharacteristicNotification(it, true) - it.value = AIRPODS_START_SOUND_OPCODE - gatt.writeCharacteristic(it) - Timber.d("Playing sound on Find My device with ${it.uuid}") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_RUNNING) - } - } - - @SuppressLint("MissingPermission") - fun stopSoundOnAirPods(gatt: BluetoothGatt) { - val service = gatt.services.firstOrNull { - it.uuid.toString().lowercase().contains( - AIRPODS_SOUND_SERVICE.lowercase() - ) - } - - if (service == null) { - Timber.d("Sound service not found") - return - } - - val uuid = AIRPODS_SOUND_CHARACTERISTIC - val characteristic = service.getCharacteristic(uuid) - characteristic.let { - gatt.setCharacteristicNotification(it, true) - it.value = AIRPODS_STOP_SOUND_OPCODE - gatt.writeCharacteristic(it) - Timber.d("Stopping sound on Find My device with ${it.uuid}") - - } - } - - @SuppressLint("MissingPermission") - override fun onCharacteristicWrite( - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic?, - status: Int - ) { - if (status == BluetoothGatt.GATT_SUCCESS) { - Timber.d("Finished writing to characteristic") - if (characteristic?.value.contentEquals(AIRPODS_START_SOUND_OPCODE) && gatt != null) { - Handler(Looper.getMainLooper()).postDelayed({ - stopSoundOnAirPods(gatt) - }, 5000) - } - - if (characteristic?.value.contentEquals(AIRPODS_STOP_SOUND_OPCODE)) { - disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_COMPLETED) - } - - } else { - Timber.d("Writing to characteristic failed ${characteristic?.uuid}") - disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) - } - super.onCharacteristicWrite(gatt, characteristic, status) - } - } + override val soundService: String + get() = AIRPODS_SOUND_SERVICE + + override val soundCharacteristic: UUID + get() = AIRPODS_SOUND_CHARACTERISTIC + + override val startSoundOpcode: ByteArray + get() = AIRPODS_START_SOUND_OPCODE + + override val stopSoundOpcode: ByteArray + get() = AIRPODS_STOP_SOUND_OPCODE + companion object : DeviceContext { internal const val AIRPODS_SOUND_SERVICE = "fd44" diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirTag.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirTag.kt index a3f81935..273b9e5b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirTag.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AirTag.kt @@ -7,12 +7,15 @@ import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothProfile import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult +import android.os.Build import androidx.annotation.DrawableRes import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.database.models.device.* -import de.seemoo.at_tracking_detection.util.ble.BluetoothConstants +import de.seemoo.at_tracking_detection.util.ble.BluetoothEvent import timber.log.Timber +import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.* class AirTag(val id: Int) : Device(), Connectable { @@ -37,8 +40,8 @@ class AirTag(val id: Int) : Device(), Connectable { BluetoothGatt.GATT_SUCCESS -> { when (newState) { BluetoothProfile.STATE_CONNECTED -> gatt.discoverServices() - BluetoothProfile.STATE_DISCONNECTED -> broadcastUpdate( - BluetoothConstants.ACTION_GATT_DISCONNECTED + BluetoothProfile.STATE_DISCONNECTED -> sendBluetoothEvent( + BluetoothEvent.Disconnected ) else -> { Timber.d("Connection state changed to $newState") @@ -46,9 +49,9 @@ class AirTag(val id: Int) : Device(), Connectable { } } 19 -> { - broadcastUpdate(BluetoothConstants.ACTION_EVENT_COMPLETED) + sendBluetoothEvent(BluetoothEvent.EventCompleted) } - else -> broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + else -> sendBluetoothEvent(BluetoothEvent.EventFailed) } } @@ -59,13 +62,21 @@ class AirTag(val id: Int) : Device(), Connectable { if (service == null) { Timber.e("AirTag sound service not found!") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } else { service.getCharacteristic(AIR_TAG_SOUND_CHARACTERISTIC) .let { - it.setValue(175, BluetoothGattCharacteristic.FORMAT_UINT8, 0) - gatt.writeCharacteristic(it) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_RUNNING) + // Use modern API for SDK 33+, fallback to deprecated for older versions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val value = ByteBuffer.allocate(1).order(ByteOrder.LITTLE_ENDIAN).put(175.toByte()).array() + gatt.writeCharacteristic(it, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + } else { + @Suppress("DEPRECATION") + it.setValue(175, BluetoothGattCharacteristic.FORMAT_UINT8, 0) + @Suppress("DEPRECATION") + gatt.writeCharacteristic(it) + } + sendBluetoothEvent(BluetoothEvent.EventRunning) Timber.d("Playing sound...") } } @@ -77,36 +88,40 @@ class AirTag(val id: Int) : Device(), Connectable { characteristic: BluetoothGattCharacteristic?, status: Int ) { - if (status == BluetoothGatt.GATT_SUCCESS && characteristic != null) { - when (characteristic.properties and AIR_TAG_EVENT_CALLBACK) { - AIR_TAG_EVENT_CALLBACK -> { - broadcastUpdate( - BluetoothConstants.ACTION_EVENT_COMPLETED - ) - disconnect(gatt) + when (status) { + BluetoothGatt.GATT_SUCCESS if characteristic != null -> { + when (characteristic.properties and AIR_TAG_EVENT_CALLBACK) { + AIR_TAG_EVENT_CALLBACK -> { + sendBluetoothEvent( + BluetoothEvent.EventCompleted + ) + disconnect(gatt) + } } } - } - else if (status == 133) { // GATT_ERROR, Timeout - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) - disconnect(gatt) - }else { - disconnect(gatt) + 133 -> { + // GATT_ERROR, Timeout + sendBluetoothEvent(BluetoothEvent.EventFailed) + disconnect(gatt) + } + else -> { + disconnect(gatt) + } } super.onCharacteristicWrite(gatt, characteristic, status) } - @Deprecated("Deprecated in Java") override fun onCharacteristicRead( - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic?, + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, status: Int ) { - if (status == BluetoothGatt.GATT_SUCCESS && characteristic != null) { + if (status == BluetoothGatt.GATT_SUCCESS) { when (characteristic.properties and AIR_TAG_EVENT_CALLBACK) { AIR_TAG_EVENT_CALLBACK -> { - broadcastUpdate( - BluetoothConstants.ACTION_EVENT_COMPLETED + sendBluetoothEvent( + BluetoothEvent.EventCompleted ) disconnect(gatt) } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AppleFindMy.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AppleFindMy.kt index 4ef482fa..f2da46e2 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AppleFindMy.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/AppleFindMy.kt @@ -19,11 +19,11 @@ import de.seemoo.at_tracking_detection.database.models.device.DeviceType import de.seemoo.at_tracking_detection.ui.scan.ScanFragment import de.seemoo.at_tracking_detection.ui.scan.ScanResultWrapper import de.seemoo.at_tracking_detection.util.Utility -import de.seemoo.at_tracking_detection.util.ble.BluetoothConstants +import de.seemoo.at_tracking_detection.util.ble.BluetoothEvent import timber.log.Timber import java.util.UUID -class AppleFindMy(val id: Int) : Device(), Connectable { +open class AppleFindMy(val id: Int) : Device(), Connectable { override val imageResource: Int @DrawableRes @@ -33,9 +33,21 @@ class AppleFindMy(val id: Int) : Device(), Connectable { get() = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.device_name_find_my_device_apple) .format(id) + override val deviceContext: DeviceContext get() = AppleFindMy + protected open val soundService: String + get() = FINDMY_SOUND_SERVICE + + protected open val soundCharacteristic: UUID + get() = FINDMY_SOUND_CHARACTERISTIC + + protected open val startSoundOpcode: ByteArray + get() = FINDMY_START_SOUND_OPCODE + + protected open val stopSoundOpcode: ByteArray + get() = FINDMY_STOP_SOUND_OPCODE override val bluetoothGattCallback: BluetoothGattCallback get() = object : BluetoothGattCallback() { @@ -49,7 +61,7 @@ class AppleFindMy(val id: Int) : Device(), Connectable { gatt.discoverServices() } BluetoothProfile.STATE_DISCONNECTED -> { - broadcastUpdate(BluetoothConstants.ACTION_GATT_DISCONNECTED) + sendBluetoothEvent(BluetoothEvent.Disconnected) Timber.d("Disconnected from gatt device!") } else -> { @@ -59,7 +71,7 @@ class AppleFindMy(val id: Int) : Device(), Connectable { } else -> { Timber.e("Failed to connect to bluetooth device! Status: $status") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } } } @@ -70,32 +82,31 @@ class AppleFindMy(val id: Int) : Device(), Connectable { Timber.d("Found UUIDS $uuids") val service = gatt.services.firstOrNull { it.uuid.toString().lowercase().contains( - FINDMY_SOUND_SERVICE.lowercase() + soundService.lowercase() ) } if (service == null) { Timber.e("Playing sound service not found!") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) return } - val characteristic = service.getCharacteristic(FINDMY_SOUND_CHARACTERISTIC) + val characteristic = service.getCharacteristic(soundCharacteristic) characteristic.let { gatt.setCharacteristicNotification(it, true) - if (Build.VERSION.SDK_INT >= 33) { - it.writeType - gatt.writeCharacteristic(it, FINDMY_START_SOUND_OPCODE, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) - }else { - // Deprecated since 33 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(it, startSoundOpcode, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + } else { @Suppress("DEPRECATION") - it.value = FINDMY_START_SOUND_OPCODE + it.value = startSoundOpcode + it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") gatt.writeCharacteristic(it) } Timber.d("Playing sound on Find My device with ${it.uuid}") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_RUNNING) + sendBluetoothEvent(BluetoothEvent.EventRunning) } } @@ -104,7 +115,7 @@ class AppleFindMy(val id: Int) : Device(), Connectable { fun stopSoundOnFindMyDevice(gatt: BluetoothGatt) { val service = gatt.services.firstOrNull { it.uuid.toString().lowercase().contains( - FINDMY_SOUND_SERVICE + soundService ) } @@ -113,17 +124,16 @@ class AppleFindMy(val id: Int) : Device(), Connectable { return } - val uuid = FINDMY_SOUND_CHARACTERISTIC + val uuid = soundCharacteristic val characteristic = service.getCharacteristic(uuid) characteristic.let { gatt.setCharacteristicNotification(it, true) - if (Build.VERSION.SDK_INT >= 33) { - it.writeType - gatt.writeCharacteristic(it, FINDMY_STOP_SOUND_OPCODE, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) - }else { - // Deprecated since 33 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(it, stopSoundOpcode, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) + } else { @Suppress("DEPRECATION") - it.value = FINDMY_STOP_SOUND_OPCODE + it.value = stopSoundOpcode + it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") gatt.writeCharacteristic(it) } @@ -140,24 +150,33 @@ class AppleFindMy(val id: Int) : Device(), Connectable { ) { if (status == BluetoothGatt.GATT_SUCCESS) { Timber.d("Finished writing to characteristic") - if (characteristic?.value.contentEquals(FINDMY_START_SOUND_OPCODE) && gatt != null) { - Handler(Looper.getMainLooper()).postDelayed({ - stopSoundOnFindMyDevice(gatt) - }, 5000) - } - - if (characteristic?.value.contentEquals(FINDMY_STOP_SOUND_OPCODE)) { - disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_COMPLETED) + if (gatt != null) { + // Use getValueForComparison to get the last written value + val lastWrittenValue = getLastWrittenValue(characteristic) + if (lastWrittenValue.contentEquals(startSoundOpcode)) { + Handler(Looper.getMainLooper()).postDelayed({ + stopSoundOnFindMyDevice(gatt) + }, 5000) + } else if (lastWrittenValue.contentEquals(stopSoundOpcode)) { + disconnect(gatt) + sendBluetoothEvent(BluetoothEvent.EventCompleted) + } } } else { Timber.d("Writing to characteristic failed ${characteristic?.uuid}") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } super.onCharacteristicWrite(gatt, characteristic, status) } + + private fun getLastWrittenValue(characteristic: BluetoothGattCharacteristic?): ByteArray { + return characteristic?.let { + @Suppress("DEPRECATION") + it.value ?: ByteArray(0) + } ?: ByteArray(0) + } } companion object : DeviceContext { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetwork.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetwork.kt index 1afe51fc..378ab3d7 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetwork.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetwork.kt @@ -7,6 +7,7 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult @@ -29,7 +30,7 @@ import de.seemoo.at_tracking_detection.database.models.device.types.GoogleFindMy import de.seemoo.at_tracking_detection.ui.scan.ScanFragment import de.seemoo.at_tracking_detection.ui.scan.ScanResultWrapper import de.seemoo.at_tracking_detection.util.Utility -import de.seemoo.at_tracking_detection.util.ble.BluetoothConstants +import de.seemoo.at_tracking_detection.util.ble.BluetoothEvent import timber.log.Timber import java.net.URL import java.util.UUID @@ -61,7 +62,7 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { gatt.discoverServices() } BluetoothProfile.STATE_DISCONNECTED -> { - broadcastUpdate(BluetoothConstants.ACTION_GATT_DISCONNECTED) + sendBluetoothEvent(BluetoothEvent.Disconnected) Timber.d("Disconnected from gatt device!") } else -> { @@ -71,7 +72,7 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { } else -> { Timber.e("Failed to connect to bluetooth device! Status: $status") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } } } @@ -89,7 +90,7 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { if (service == null) { Timber.e("Playing sound service not found!") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) return } @@ -97,18 +98,17 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { characteristic.let { gatt.setCharacteristicNotification(it, true) - if (Build.VERSION.SDK_INT >= 33) { - it.writeType + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(it, GOOGLE_START_SOUND_OPCODE, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } else { - // Deprecated since 33 @Suppress("DEPRECATION") it.value = GOOGLE_START_SOUND_OPCODE + it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") gatt.writeCharacteristic(it) } Timber.d("Playing sound on Find My device with ${it.uuid}") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_RUNNING) + sendBluetoothEvent(BluetoothEvent.EventRunning) } } @@ -129,13 +129,12 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { val characteristic = service.getCharacteristic(uuid) characteristic.let { gatt.setCharacteristicNotification(it, true) - if (Build.VERSION.SDK_INT >= 33) { - it.writeType + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(it, GOOGLE_STOP_SOUND_OPCODE, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } else { - // Deprecated since 33 @Suppress("DEPRECATION") it.value = GOOGLE_STOP_SOUND_OPCODE + it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") gatt.writeCharacteristic(it) } @@ -151,24 +150,33 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { ) { if (status == BluetoothGatt.GATT_SUCCESS) { Timber.d("Finished writing to characteristic") - if (characteristic?.value.contentEquals(GOOGLE_START_SOUND_OPCODE) && gatt != null) { - Handler(Looper.getMainLooper()).postDelayed({ - stopSoundOnGoogleDevice(gatt) - }, 5000) - } - - if (characteristic?.value.contentEquals(GOOGLE_STOP_SOUND_OPCODE)) { - disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_COMPLETED) + if (gatt != null) { + // Use internal tracking since characteristic.value is deprecated + val lastWrittenValue = getLastWrittenValue(characteristic) + if (lastWrittenValue.contentEquals(GOOGLE_START_SOUND_OPCODE)) { + Handler(Looper.getMainLooper()).postDelayed({ + stopSoundOnGoogleDevice(gatt) + }, 5000) + } else if (lastWrittenValue.contentEquals(GOOGLE_STOP_SOUND_OPCODE)) { + disconnect(gatt) + sendBluetoothEvent(BluetoothEvent.EventCompleted) + } } } else { Timber.d("Writing to characteristic failed ${characteristic?.uuid}") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } super.onCharacteristicWrite(gatt, characteristic, status) } + + private fun getLastWrittenValue(characteristic: BluetoothGattCharacteristic?): ByteArray { + return characteristic?.let { + @Suppress("DEPRECATION") + it.value ?: ByteArray(0) + } ?: ByteArray(0) + } } companion object : DeviceContext { @@ -213,8 +221,9 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { suspend fun getDeviceName(wrappedScanResult: ScanResultWrapper): String { val errorCaseName = GoogleFindMyNetworkType.visibleStringFromSubtype(getSubType(wrappedScanResult)) - val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() val context: Context = ATTrackingDetectionApplication.getAppContext() + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) { Timber.e("Bluetooth adapter is null or not enabled") @@ -245,8 +254,9 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { } suspend fun getOwnerInformationURL(wrappedScanResult: ScanResultWrapper): URL? { - val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() val context: Context = ATTrackingDetectionApplication.getAppContext() + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) { Timber.e("Bluetooth adapter is null or not enabled") @@ -323,8 +333,8 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { val descriptor = characteristic.getDescriptor(GOOGLE_DESCRIPTOR_UUID) - // Write data to the descriptor - if (Build.VERSION.SDK_INT >= 33) { + // Write data to the descriptor using modern API + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) } else { @Suppress("DEPRECATION") @@ -354,14 +364,14 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { return } - if (Build.VERSION.SDK_INT >= 33) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(characteristicToWrite, dataToSend, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } else { @Suppress("DEPRECATION") characteristicToWrite.value = dataToSend characteristicToWrite.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") - (gatt.writeCharacteristic(characteristicToWrite)) + gatt.writeCharacteristic(characteristicToWrite) } } else { continuation.resume(null) @@ -401,7 +411,8 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { fun nameReplacementLayer(name: String): String { return when { - name.contains("motorola", ignoreCase = true) -> "Motorola Moto Tag" + name.contains("motorola", ignoreCase = true) -> "Moto Tag" + name.contains("TBD-Gray", ignoreCase = false) -> "Moto Tag" else -> name } } @@ -412,10 +423,12 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { name.contains("pebblebee", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.PEBBLEBEE name.contains("chipolo", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.CHIPOLO name.contains("motorola", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.MOTOROLA - name.contains("moto", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.MOTOROLA + name.contains("moto tag", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.MOTOROLA + name.contains("TBD-Gray", ignoreCase = false) -> GoogleFindMyNetworkManufacturer.MOTOROLA name.contains("eufy", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.EUFY name.contains("jio", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.JIO name.contains("rolling square", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.ROLLING_SQUARE + name.contains("hama", ignoreCase = true) -> GoogleFindMyNetworkManufacturer.HAMA else -> GoogleFindMyNetworkManufacturer.UNKNOWN } } @@ -424,6 +437,10 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { return when { name.contains("pebblebee clip", ignoreCase = true) -> R.drawable.ic_pebblebee_clip name.contains("chipolo one", ignoreCase = true) -> R.drawable.ic_chipolo + name.contains("motorola", ignoreCase = true) -> R.drawable.ic_moto_tag + name.contains("moto tag", ignoreCase = true) -> R.drawable.ic_moto_tag + name.contains("TBD-Gray", ignoreCase = false) -> R.drawable.ic_moto_tag + name.contains("hama", ignoreCase = true) -> R.drawable.ic_hama_tracker else -> R.drawable.ic_chipolo } } @@ -434,6 +451,7 @@ class GoogleFindMyNetwork(val id: Int) : Device(), Connectable { GoogleFindMyNetworkManufacturer.PEBBLEBEE -> ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.retrieve_owner_information_explanation_pebblebee) GoogleFindMyNetworkManufacturer.CHIPOLO -> ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.retrieve_owner_information_explanation_chipolo) GoogleFindMyNetworkManufacturer.MOTOROLA -> ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.retrieve_owner_information_explanation_motorola) + GoogleFindMyNetworkManufacturer.HAMA -> ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.retrieve_owner_information_explanation_hama) else -> ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.retrieve_owner_information_explanation_unknown) } } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetworkManufacturer.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetworkManufacturer.kt index 457c3ac5..a17c9e57 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetworkManufacturer.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/GoogleFindMyNetworkManufacturer.kt @@ -3,8 +3,9 @@ package de.seemoo.at_tracking_detection.database.models.device.types enum class GoogleFindMyNetworkManufacturer { PEBBLEBEE, CHIPOLO, - EUFY, MOTOROLA, + HAMA, + EUFY, JIO, ROLLING_SQUARE, UNKNOWN diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/PebbleBee.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/PebbleBee.kt index d59a29a1..eb806212 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/PebbleBee.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/types/PebbleBee.kt @@ -21,7 +21,7 @@ import de.seemoo.at_tracking_detection.database.models.device.DeviceContext import de.seemoo.at_tracking_detection.database.models.device.DeviceType import de.seemoo.at_tracking_detection.ui.scan.ScanFragment import de.seemoo.at_tracking_detection.ui.scan.ScanResultWrapper -import de.seemoo.at_tracking_detection.util.ble.BluetoothConstants +import de.seemoo.at_tracking_detection.util.ble.BluetoothEvent import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber import java.util.UUID @@ -52,7 +52,7 @@ class PebbleBee (val id: Int) : Device(), Connectable { gatt.discoverServices() } BluetoothProfile.STATE_DISCONNECTED -> { - broadcastUpdate(BluetoothConstants.ACTION_GATT_DISCONNECTED) + sendBluetoothEvent(BluetoothEvent.Disconnected) Timber.d("Disconnected from gatt device!") } else -> { @@ -62,7 +62,7 @@ class PebbleBee (val id: Int) : Device(), Connectable { } else -> { Timber.e("Failed to connect to bluetooth device! Status: $status") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } } } @@ -84,7 +84,7 @@ class PebbleBee (val id: Int) : Device(), Connectable { if (service == null) { Timber.e("Playing sound service not found!") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) return } @@ -95,14 +95,18 @@ class PebbleBee (val id: Int) : Device(), Connectable { if (characteristic == null) { Timber.e("Characteristic not found!") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) return } gatt.setCharacteristicNotification(characteristic, true) - if (Build.VERSION.SDK_INT >= 33) { - gatt.writeCharacteristic(characteristic, PEBBLEBEE_START_SOUND_OPCODE, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + characteristic, + PEBBLEBEE_START_SOUND_OPCODE, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) } else { @Suppress("DEPRECATION") characteristic.value = PEBBLEBEE_START_SOUND_OPCODE @@ -112,7 +116,7 @@ class PebbleBee (val id: Int) : Device(), Connectable { } Timber.d("Playing sound on Find My device with ${characteristic.uuid}") - broadcastUpdate(BluetoothConstants.ACTION_EVENT_RUNNING) + sendBluetoothEvent(BluetoothEvent.EventRunning) } @SuppressLint("MissingPermission") @@ -132,13 +136,12 @@ class PebbleBee (val id: Int) : Device(), Connectable { val characteristic = service.getCharacteristic(uuid) characteristic.let { gatt.setCharacteristicNotification(it, true) - if (Build.VERSION.SDK_INT >= 33) { - it.writeType + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(it, PEBBLEBEE_STOP_SOUND_OPCODE, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } else { - // Deprecated since 33 @Suppress("DEPRECATION") it.value = PEBBLEBEE_STOP_SOUND_OPCODE + it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT @Suppress("DEPRECATION") gatt.writeCharacteristic(it) } @@ -154,24 +157,32 @@ class PebbleBee (val id: Int) : Device(), Connectable { ) { if (status == BluetoothGatt.GATT_SUCCESS) { Timber.d("Finished writing to characteristic") - if (characteristic?.value.contentEquals(PEBBLEBEE_START_SOUND_OPCODE) && gatt != null) { - Handler(Looper.getMainLooper()).postDelayed({ - stopSoundOnPebbleBeeDevice(gatt) - }, 5000) - } - - if (characteristic?.value.contentEquals(PEBBLEBEE_STOP_SOUND_OPCODE)) { - disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_COMPLETED) + if (gatt != null) { + val lastWrittenValue = getLastWrittenValue(characteristic) + if (lastWrittenValue.contentEquals(PEBBLEBEE_START_SOUND_OPCODE)) { + Handler(Looper.getMainLooper()).postDelayed({ + stopSoundOnPebbleBeeDevice(gatt) + }, 5000) + } else if (lastWrittenValue.contentEquals(PEBBLEBEE_STOP_SOUND_OPCODE)) { + disconnect(gatt) + sendBluetoothEvent(BluetoothEvent.EventCompleted) + } } } else { Timber.d("Writing to characteristic failed ${characteristic?.uuid}") disconnect(gatt) - broadcastUpdate(BluetoothConstants.ACTION_EVENT_FAILED) + sendBluetoothEvent(BluetoothEvent.EventFailed) } super.onCharacteristicWrite(gatt, characteristic, status) } + + private fun getLastWrittenValue(characteristic: BluetoothGattCharacteristic?): ByteArray { + return characteristic?.let { + @Suppress("DEPRECATION") + it.value ?: ByteArray(0) + } ?: ByteArray(0) + } } companion object : DeviceContext { @@ -240,21 +251,23 @@ class PebbleBee (val id: Int) : Device(), Connectable { } } - @Deprecated("Deprecated in Java") @SuppressLint("MissingPermission") - override fun onCharacteristicRead(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { if (status == BluetoothGatt.GATT_SUCCESS) { - when (characteristic?.uuid) { + when (characteristic.uuid) { GATT_DEVICE_TYPE_CHARACTERISTIC -> { - if (characteristic != null) { - deviceName = characteristic.getStringValue(0) - } + deviceName = String(value, Charsets.UTF_8) } } } else { Timber.w("Failed to read characteristic: $status") } - gatt?.let { readNextCharacteristic(it) } + readNextCharacteristic(gatt) } @SuppressLint("MissingPermission") diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/BeaconRepository.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/BeaconRepository.kt index e6d570c8..1b75d218 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/BeaconRepository.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/BeaconRepository.kt @@ -37,6 +37,9 @@ class BeaconRepository @Inject constructor( fun getDeviceBeacons(deviceAddress: String): List = beaconDao.getDeviceBeacons(deviceAddress) + fun getRecentDeviceBeacons(deviceAddress: String, limit: Int): List = + beaconDao.getDeviceBeaconsLimit(deviceAddress, limit) + fun getDeviceBeaconsFlow(deviceAddress: String): Flow> = beaconDao.observeDeviceBeacons(deviceAddress) @@ -67,4 +70,6 @@ class BeaconRepository @Inject constructor( } fun getBeaconsOlderThanWithoutNotifications(deleteEverythingBefore: LocalDateTime): List = beaconDao.getBeaconsOlderThanWithoutNotifications(deleteEverythingBefore) + + fun getMostRecentBeaconAtLocation(locationId: Int): Beacon? = beaconDao.getMostRecentBeaconAtLocation(locationId) } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/DeviceRepository.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/DeviceRepository.kt index dc3cf814..cf7d23a1 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/DeviceRepository.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/DeviceRepository.kt @@ -16,6 +16,12 @@ import javax.inject.Inject class DeviceRepository @Inject constructor(private val deviceDao: DeviceDao) { val devices: Flow> = deviceDao.getAll() + suspend fun toggleHeart(address: String, hearted: Boolean) { + val device = deviceDao.getByAddress(address) ?: return + device.hearted = hearted + deviceDao.update(device) + } + fun trackingDevicesSince(since: LocalDateTime) = deviceDao.getAllNotificationSince(since) fun trackingDevicesNotIgnoredSince(since: LocalDateTime) = deviceDao.getAllTrackingDevicesNotIgnoredSince(since) @@ -71,6 +77,10 @@ class DeviceRepository @Inject constructor(private val deviceDao: DeviceDao) { fun getDeviceWithConnectableStateSince(deviceType: DeviceType, since: LocalDateTime, connectableState: Boolean): BaseDevice? = deviceDao.getDeviceWithConnectableStateSince(deviceType.name, since, connectableState) + fun getDeviceCountAtLocation(locationId: Int, since: LocalDateTime): Int = deviceDao.getDeviceCountAtLocation(locationId, since) + + fun getDevicesAtLocation(locationId: Int, since: LocalDateTime): List = deviceDao.getDevicesAtLocation(locationId, since) + // @WorkerThread // suspend fun getDeviceBeaconsSince(dateTime: String?): List { // return if (dateTime != null) { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/FeedbackRepository.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/FeedbackRepository.kt index 37e5d37a..f2a8c94d 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/FeedbackRepository.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/FeedbackRepository.kt @@ -9,7 +9,7 @@ import javax.inject.Singleton @Singleton class FeedbackRepository @Inject constructor(private val feedbackDao: FeedbackDao) { - fun getFeedback(notificationId: Int): Feedback = feedbackDao.getFeedback(notificationId) + fun getFeedback(notificationId: Int): Feedback? = feedbackDao.getFeedback(notificationId) @WorkerThread suspend fun insert(feedback: Feedback): Long { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/LocationRepository.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/LocationRepository.kt index b6b88a8b..e4affe57 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/LocationRepository.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/LocationRepository.kt @@ -31,8 +31,8 @@ class LocationRepository @Inject constructor( fun getLocationsWithNoBeacons(): List = locationDao.getLocationsWithNoBeacons() @WorkerThread - suspend fun insert(location: LocationModel) { - locationDao.insert(location) + suspend fun insert(location: LocationModel): Long { + return locationDao.insert(location) } @WorkerThread diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/NotificationRepository.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/NotificationRepository.kt index ef7f6a80..91de1e08 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/NotificationRepository.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/repository/NotificationRepository.kt @@ -75,4 +75,9 @@ class NotificationRepository @Inject constructor( suspend fun setClicked(notificationId: Int, state: Boolean) { notificationDao.setClicked(notificationId, state) } + + @WorkerThread + suspend fun deleteForDevice(deviceAddress: String) { + notificationDao.deleteForDevice(deviceAddress) + } } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt index 7db0eefc..bce4f932 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt @@ -73,21 +73,25 @@ object BackgroundBluetoothScanner { val backgroundWorkScheduler: BackgroundWorkScheduler get() { - return ATTrackingDetectionApplication.getCurrentApp().backgroundWorkScheduler + return ATTrackingDetectionApplication.getCurrentApp()?.backgroundWorkScheduler + ?: error("ATTrackingDetectionApplication not initialized") } val notificationService: NotificationService get() { - return ATTrackingDetectionApplication.getCurrentApp().notificationService + return ATTrackingDetectionApplication.getCurrentApp()?.notificationService + ?: error("ATTrackingDetectionApplication not initialized") } private val locationProvider: LocationProvider get() { - return ATTrackingDetectionApplication.getCurrentApp().locationProvider + return ATTrackingDetectionApplication.getCurrentApp()?.locationProvider + ?: error("ATTrackingDetectionApplication not initialized") } private val scanRepository: ScanRepository get() { - return ATTrackingDetectionApplication.getCurrentApp().scanRepository + return ATTrackingDetectionApplication.getCurrentApp()?.scanRepository + ?: error("ATTrackingDetectionApplication not initialized") } private var isScanning = false @@ -99,8 +103,8 @@ object BackgroundBluetoothScanner { var failed: Boolean ) - suspend fun scanInBackground(startedFrom: String): BackgroundScanResults { - if (SharedPrefs.deactivateBackgroundScanning) { + suspend fun scanInBackground(startedFrom: String, ignoreDeactivatedSetting: Boolean = false): BackgroundScanResults { + if (SharedPrefs.deactivateBackgroundScanning && !ignoreDeactivatedSetting) { Timber.d("Background scanning is deactivated") return BackgroundScanResults(0, 0, 0, true) } @@ -253,8 +257,11 @@ object BackgroundBluetoothScanner { scan.noDevicesFound = scanResultDictionary.size if (BuildConfig.DEBUG) { - val dbLocation = saveLocation(latitude = location?.latitude, longitude = location?.longitude, accuracy = location?.accuracy, altitude = location?.altitude, discoveryDate = LocalDateTime.now()) - scan.locationId = dbLocation?.locationId + val (dbLocation, dbLocationWasNew) = saveLocation(latitude = location?.latitude, longitude = location?.longitude, accuracy = location?.accuracy, altitude = location?.altitude, discoveryDate = LocalDateTime.now()) + // Only attach an existing location to the scan record. + if (!dbLocationWasNew) { + scan.locationId = dbLocation?.locationId + } scan.locationDeg = "${location?.longitude},${location?.latitude}" scan.devicesAddressesFound = scanResultDictionary.keys.joinToString(separator = ",") scan.devicesTypesFound = scanResultDictionary.values.map{it.wrappedScanResult.deviceType}.toSet().joinToString(separator = ",") @@ -401,13 +408,14 @@ object BackgroundBluetoothScanner { ) // return when device does not qualify to be saved // set locationId to null if gps location could not be retrieved - val locId: Int? = saveLocation( + val (savedLocation, locationWasNewlyCreated) = saveLocation( latitude = latitude, longitude = longitude, altitude = altitude, discoveryDate = discoveryDate, accuracy = accuracy - )?.locationId + ) + val locId: Int? = savedLocation?.locationId // For 15 minute algorithm var overrideIdentifier: String? = null @@ -416,7 +424,30 @@ object BackgroundBluetoothScanner { overrideIdentifier = deviceSaved.address } - val beaconSaved = saveBeacon(wrappedScanResult, discoveryDate, locId, overrideIdentifier) ?: return@withLock Pair(null, null) + // Fix for Google Find My Network devices + if (overrideIdentifier == null && deviceSaved.address != wrappedScanResult.uniqueIdentifier) { + Timber.d("saveDevice() resolved to a different address (${deviceSaved.address}) than the scanned identifier (${wrappedScanResult.uniqueIdentifier}). Using saved address for beacon.") + overrideIdentifier = deviceSaved.address + } + + val beaconSaved = saveBeacon(wrappedScanResult, discoveryDate, locId, overrideIdentifier) + + if (beaconSaved == null) { + // Fallback case: If location is created but failure during Beacon saving, delete Location again so it does not become an orphan + // This is necessary as it would become visible on the map but with no content for the info popup + if (locationWasNewlyCreated && savedLocation != null) { + val locationRepository = ATTrackingDetectionApplication.getCurrentApp()?.locationRepository + ?: error("ATTrackingDetectionApplication not initialized") + val beaconCountAtLocation = locationRepository.getNumberOfBeaconsForLocation(savedLocation.locationId) + if (beaconCountAtLocation == 0) { + Timber.d("Beacon saving failed for a newly created location (id=${savedLocation.locationId}), deleting orphan location.") + locationRepository.delete(savedLocation) + } else { + Timber.d("Beacon saving failed but location (id=${savedLocation.locationId}) is still referenced by $beaconCountAtLocation beacon(s), keeping it.") + } + } + return@withLock Pair(null, null) + } return@withLock Pair(deviceSaved, beaconSaved) } @@ -432,7 +463,8 @@ object BackgroundBluetoothScanner { return withContext(Dispatchers.IO) { beaconMutex.withLock { val beaconRepository = - ATTrackingDetectionApplication.getCurrentApp().beaconRepository + ATTrackingDetectionApplication.getCurrentApp()?.beaconRepository + ?: error("ATTrackingDetectionApplication not initialized") val uuids = wrappedScanResult.serviceUuids val uniqueIdentifier = overrideIdentifier ?: wrappedScanResult.uniqueIdentifier @@ -510,7 +542,8 @@ object BackgroundBluetoothScanner { ): BaseDevice? { return withContext(Dispatchers.IO) { deviceMutex.withLock { - val deviceRepository = ATTrackingDetectionApplication.getCurrentApp().deviceRepository + val deviceRepository = ATTrackingDetectionApplication.getCurrentApp()?.deviceRepository + ?: error("ATTrackingDetectionApplication not initialized") val deviceAddress = wrappedScanResult.uniqueIdentifier // Checks if Device already exists in device database @@ -702,16 +735,17 @@ object BackgroundBluetoothScanner { altitude: Double?, discoveryDate: LocalDateTime, accuracy: Float? - ): Location? { + ): Pair { return withContext(Dispatchers.IO) { locationMutex.withLock { if (altitude != null && altitude > TrackingDetectorConstants.IGNORE_LOCATION_ABOVE_ALTITUDE) { Timber.d("Ignoring location above ${TrackingDetectorConstants.IGNORE_LOCATION_ABOVE_ALTITUDE}m, we assume the User might be on a plane!") // Do not save location object - return@withLock null + return@withLock Pair(null, false) } - val locationRepository = ATTrackingDetectionApplication.getCurrentApp().locationRepository + val locationRepository = ATTrackingDetectionApplication.getCurrentApp()?.locationRepository + ?: error("ATTrackingDetectionApplication not initialized") // set location to null if gps location could not be retrieved var location: Location? = null @@ -731,14 +765,17 @@ object BackgroundBluetoothScanner { if (location == null || distanceBetweenLocations > MAX_DISTANCE_UNTIL_NEW_LOCATION) { // Create new location entry Timber.d("Add new Location to the database!") - location = Location( + val newLocation = Location( firstDiscovery = discoveryDate, longitude = longitude, latitude = latitude, altitude = altitude, accuracy = accuracy, ) - locationRepository.insert(location) + val newRowId = locationRepository.insert(newLocation) + location = locationRepository.getLocationWithId(newRowId.toInt()) + Timber.d("Inserted new Location with rowId=$newRowId, fetched: $location") + return@withLock Pair(location, true) } else { // If location is within the set limit, just use that location and update lastSeen Timber.d("Location already in the database... Updating the last seen date!") @@ -756,7 +793,7 @@ object BackgroundBluetoothScanner { Timber.d("Location: $location") } - return@withLock location + return@withLock Pair(location, false) } } } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt index e05baa88..15758178 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt @@ -85,23 +85,27 @@ object PermanentBluetoothScanner: LocationHistoryListener { val backgroundWorkScheduler: BackgroundWorkScheduler get() { - return ATTrackingDetectionApplication.getCurrentApp().backgroundWorkScheduler + return ATTrackingDetectionApplication.getCurrentApp()?.backgroundWorkScheduler + ?: error("ATTrackingDetectionApplication not initialized") } val notificationService: NotificationService get() { - return ATTrackingDetectionApplication.getCurrentApp().notificationService + return ATTrackingDetectionApplication.getCurrentApp()?.notificationService + ?: error("ATTrackingDetectionApplication not initialized") } private val locationProvider: LocationProvider get() { - return ATTrackingDetectionApplication.getCurrentApp().locationProvider + return ATTrackingDetectionApplication.getCurrentApp()?.locationProvider + ?: error("ATTrackingDetectionApplication not initialized") } private val validDeviceTypes = DeviceType.getAllowedDeviceTypesFromSettings() private val scanRepository: ScanRepository get() { - return ATTrackingDetectionApplication.getCurrentApp().scanRepository + return ATTrackingDetectionApplication.getCurrentApp()?.scanRepository + ?: error("ATTrackingDetectionApplication not initialized") } private var isScanning = false diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/ScanBluetoothWorker.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/ScanBluetoothWorker.kt index 9cb4ee4d..ce4dc996 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/ScanBluetoothWorker.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/ScanBluetoothWorker.kt @@ -19,8 +19,12 @@ class ScanBluetoothWorker @AssistedInject constructor( override suspend fun doWork(): Result { + val ignoreDeactivatedSetting = inputData.getBoolean(IGNORE_DEACTIVATED_SETTING_PARAM, false) - val results = BackgroundBluetoothScanner.scanInBackground(startedFrom = "ScanBluetoothWorker") + val results = BackgroundBluetoothScanner.scanInBackground( + startedFrom = "ScanBluetoothWorker", + ignoreDeactivatedSetting = ignoreDeactivatedSetting + ) if (results.failed) { return Result.retry() @@ -34,4 +38,8 @@ class ScanBluetoothWorker @AssistedInject constructor( .build() ) } + + companion object { + const val IGNORE_DEACTIVATED_SETTING_PARAM = "IGNORE_DEACTIVATED_SETTING" + } } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/TrackingDetectorWorker.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/TrackingDetectorWorker.kt index 2606a0c4..9f957cca 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/TrackingDetectorWorker.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/TrackingDetectorWorker.kt @@ -9,13 +9,10 @@ import androidx.work.Data import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication -import de.seemoo.at_tracking_detection.database.repository.BeaconRepository -import de.seemoo.at_tracking_detection.database.repository.DeviceRepository import de.seemoo.at_tracking_detection.database.models.Beacon import de.seemoo.at_tracking_detection.database.models.device.BaseDevice -import de.seemoo.at_tracking_detection.database.models.device.ConnectionState -import de.seemoo.at_tracking_detection.database.models.device.DeviceType +import de.seemoo.at_tracking_detection.database.repository.BeaconRepository +import de.seemoo.at_tracking_detection.database.repository.DeviceRepository import de.seemoo.at_tracking_detection.database.repository.NotificationRepository import de.seemoo.at_tracking_detection.notifications.NotificationService import de.seemoo.at_tracking_detection.util.SharedPrefs @@ -69,10 +66,9 @@ class TrackingDetectorWorker @AssistedInject constructor( Timber.d("Tracking detector worker finished. Sent $notificationsSent notifications") try { - deleteSafeGoogleTrackers() - deleteOldAndSafeTrackers() - } catch (e:Exception) { - Timber.e("Deleting trackers failed $e") + checkTooManyNotificationsHint() + } catch (e: Exception) { + Timber.e("Checking too many notifications hint failed: $e") } return Result.success( @@ -129,80 +125,36 @@ class TrackingDetectorWorker @AssistedInject constructor( return false } - private suspend fun deleteSafeGoogleTrackers() { - // This function is necessary because: - // If an adversary constructs a device in the Find My Network where the connection state bit is always false, we still need to map this - // We assume that after a fixed time (e.g. 12 hours) it is not the if there has been no indications that it is a malicious tracking device - Timber.d("Start deleting safe google trackers") - val deleteSafeTrackersBefore = RiskLevelEvaluator.deleteSafeGoogleTrackersBeforeDate - - try { - val googleTrackersToBeDeleted = deviceRepository.getDevicesWithDeviceTypeAndConnectionStateOlderThan( - DeviceType.GOOGLE_FIND_MY_NETWORK, - ConnectionState.PREMATURE_OFFLINE, - deleteSafeTrackersBefore - ) - if (googleTrackersToBeDeleted.isNotEmpty()) { - Timber.d("Deleting ${googleTrackersToBeDeleted.size} google trackers") - deviceRepository.deleteDevices(googleTrackersToBeDeleted) - Timber.d("Deleting Google Trackers successful") - } else { - Timber.d("No Google trackers to delete") - } - } catch (e: Exception) { - Timber.e("Deleting Safe Google Trackers failed $e") - } - } - - private suspend fun deleteOldAndSafeTrackers() { - // Delete old devices and beacons from the database - Timber.d("Start deleting old and safe Trackers") - - val deleteTrackers = SharedPrefs.deleteOldDevices - if (!deleteTrackers) { - Timber.d("Deleting old devices is disabled in settings, skipping deletion") + /** + * Checks if there are too many notifications in the last 24 hours and sets a hint in SharedPrefs. + * Conditions: + * - At least x notifications in the last 24 hours OR + * - At least y notifications for the same device in the last 24 hours + */ + private fun checkTooManyNotificationsHint( + notificationsLastDay: Int = 5, + notificationsPerDeviceLastDay: Int = 3 + ) { + val since = LocalDateTime.now().minusHours(24) + val recentNotifications = notificationRepository.notificationsSince(since) + + // Check if there are at least x notifications in total + val totalNotifications = recentNotifications.size + if (totalNotifications == notificationsLastDay) { + SharedPrefs.showTooManyNotificationsHint = true + // TODO: Throw Notification return } - val alsoDeleteUnsafeTrackers = SharedPrefs.deleteUnsafeOldDevices - val deleteSafeTrackersBefore = RiskLevelEvaluator.deleteBeforeDate - - try { - val devicesToBeDeleted = if (alsoDeleteUnsafeTrackers) { - Timber.d("Deleting all devices older than $deleteSafeTrackersBefore") - deviceRepository.getDevicesOlderThan(deleteSafeTrackersBefore) - } else { - Timber.d("Only deleting safe trackers older than $deleteSafeTrackersBefore") - deviceRepository.getDevicesOlderThanWithoutNotifications(deleteSafeTrackersBefore) - } - - if (devicesToBeDeleted.isNotEmpty()) { - Timber.d("Deleting ${devicesToBeDeleted.size} devices") - deviceRepository.deleteDevices(devicesToBeDeleted) - Timber.d("Deleting Devices successful") - } else { - Timber.d("No old devices to delete") - } - } catch (e: Exception) { - Timber.e("Deleting Devices failed $e") - } - - try { - val locationRepository = ATTrackingDetectionApplication.getCurrentApp().locationRepository - val locationsToBeDeleted = locationRepository.getLocationsWithNoBeacons() - if (locationsToBeDeleted.isNotEmpty()) { - Timber.d("Deleting ${locationsToBeDeleted.size} locations") - locationRepository.deleteLocations(locationsToBeDeleted) - Timber.d("Deleting Locations successful") - } else { - Timber.d("No locations to delete") - } - } catch (e: Exception) { - Timber.e("Deleting Locations failed $e") + // Check if there are at least y notifications for the same device + val notificationsByDevice = recentNotifications.groupBy { it.deviceAddress } + val maxNotificationsForDevice = notificationsByDevice.values.maxOfOrNull { it.size } ?: 0 + if (maxNotificationsForDevice == notificationsPerDeviceLastDay) { + SharedPrefs.showTooManyNotificationsHint = true + // TODO: Throw Notification + return } - - Timber.d("Deleting old and safe Trackers finished") } companion object { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/MainActivity.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/MainActivity.kt index a4ff0ca0..5ad4751b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/MainActivity.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/MainActivity.kt @@ -4,31 +4,35 @@ import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.os.StrictMode +import android.view.View import android.view.ViewGroup +import android.widget.ScrollView +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.navigation.NavOptions +import androidx.core.view.marginBottom +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController -import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.BuildConfig import de.seemoo.at_tracking_detection.R +import de.seemoo.at_tracking_detection.util.MapUtils import de.seemoo.at_tracking_detection.util.SharedPrefs -import de.seemoo.at_tracking_detection.util.Utility import de.seemoo.at_tracking_detection.util.ble.BLEScanner import de.seemoo.at_tracking_detection.worker.BackgroundWorkScheduler import org.osmdroid.config.Configuration import timber.log.Timber -import java.io.File import java.time.LocalDateTime import java.time.ZoneOffset import javax.inject.Inject @@ -41,9 +45,14 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh @Inject lateinit var backgroundWorkScheduler: BackgroundWorkScheduler + private var floatingNavHeight = 0 + private lateinit var navView: BottomNavigationView + override fun onCreate(savedInstanceState: Bundle?) { Timber.d("MainActivity onCreate called") + enableEdgeToEdge() + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() .detectUnsafeIntentLaunch() @@ -52,27 +61,15 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh } super.onCreate(savedInstanceState) - - // Prevent Screenshots, if set in settings updateSecureFlag() - setContentView(R.layout.activity_main) - configureSystemBars(this, edgeToEdge = true, applyRootPadding = false) sharedPreferences.registerOnSharedPreferenceChangeListener(this) - val configuration = Configuration.getInstance() - configuration.load(this, PreferenceManager.getDefaultSharedPreferences(this)) - - val navView: BottomNavigationView = findViewById(R.id.main_nav_view) + // Initialize osmdroid configuration + MapUtils.initializeOsmDroidConfiguration(this) - configuration.userAgentValue = BuildConfig.APPLICATION_ID - // Create osmdroid folder - val osmDroidDir = File(filesDir, "osmDroid") - osmDroidDir.mkdir() - val tilesDir = File(osmDroidDir, "tiles") - configuration.osmdroidBasePath = osmDroidDir - configuration.osmdroidTileCache = tilesDir + navView = findViewById(R.id.main_nav_view) if (BuildConfig.DEBUG) { Configuration.getInstance().isDebugMode = true @@ -81,17 +78,8 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_fragment) as NavHostFragment val navController = navHostFragment.navController - // Passing each menu ID as a set of Ids because each - // menu should be considered as top level destinations. - val appBarItems: Set = setOf( - R.id.navigation_dashboard, - R.id.navigation_manual_scan, - R.id.navigation_allDevicesFragment, - R.id.navigation_settings - ) - if (BuildConfig.DEBUG) { - appBarItems.plus(R.id.navigation_debug) - } + + navView.setupWithNavController(navController) if (!SharedPrefs.advancedMode) { val menu = navView.menu @@ -99,24 +87,165 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh item.isVisible = false } - val appBarConfiguration = AppBarConfiguration(appBarItems) - setupActionBarWithNavController(navController, appBarConfiguration) - navView.setupWithNavController(navController) + // Handle Floating Nav Bar Positioning + setupFloatingBottomNavigation(navView) + + // Automagically handle Padding for Content inside Fragments + setupFragmentContentPadding() - val navOptions = NavOptions.Builder().setPopUpTo(R.id.main_navigation, inclusive = true, saveState = false).setLaunchSingleTop(true).build() + val navOptions = androidx.navigation.NavOptions.Builder() + .setPopUpTo(R.id.main_navigation, inclusive = true, saveState = false) + .setLaunchSingleTop(true) + .build() navView.setOnItemSelectedListener { when (it.itemId) { - R.id.navigation_dashboard -> navController.navigate(R.id.navigation_dashboard, args=null, navOptions = navOptions) - R.id.navigation_manual_scan -> navController.navigate(R.id.navigation_manual_scan, args=null, navOptions = navOptions) - R.id.navigation_allDevicesFragment -> navController.navigate(R.id.navigation_allDevicesFragment, args=null, navOptions = navOptions) - R.id.navigation_settings -> navController.navigate(R.id.navigation_settings, args=null, navOptions = navOptions) - R.id.navigation_debug -> navController.navigate(R.id.navigation_debug, args=null, navOptions = navOptions) + R.id.navigation_dashboard -> navController.navigate(R.id.navigation_dashboard, null, navOptions) + R.id.navigation_manual_scan -> navController.navigate(R.id.navigation_manual_scan, null, navOptions) + R.id.navigation_allDevicesFragment -> navController.navigate(R.id.navigation_allDevicesFragment, null, navOptions) + R.id.navigation_settings -> navController.navigate(R.id.navigation_settings, null, navOptions) + R.id.navigation_debug -> navController.navigate(R.id.navigation_debug, null, navOptions) } return@setOnItemSelectedListener true } } + /** + * Lifts the BottomNavigationView above the system gesture area. + * Also calculates the total height needed for content padding. + */ + private fun setupFloatingBottomNavigation(navView: BottomNavigationView) { + val originalBottomMargin = (navView.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin + + ViewCompat.setOnApplyWindowInsetsListener(navView) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // Update bottom margin to clear system gestures + view.updateLayoutParams { + bottomMargin = originalBottomMargin + bars.bottom + } + + // Store total height immediately + floatingNavHeight = view.height + view.marginBottom + if (floatingNavHeight == 0) { + // Fallback estimate if not laid out yet: approx. 80dp + bars.bottom + // (56dp nav + 16dp margin + 8dp elevation/shadow approx) + val density = view.resources.displayMetrics.density + val approximateHeight = (88 * density).toInt() + floatingNavHeight = approximateHeight + bars.bottom + } + insets + } + } + + /** + * Registers a lifecycle callback to intercept Fragments as they are created. + * It finds the scrollable view and applies padding. + * It finds FABs and applies margins. + */ + private fun setupFragmentContentPadding() { + supportFragmentManager.registerFragmentLifecycleCallbacks(object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) { + + // Handle Scrolling Content (RecyclerView, etc.) + // DevicesFragment handles its own layering + if (f.javaClass.simpleName != "DevicesFragment") { + val scrollingView = findScrollingView(v) ?: v + + // allows content to scroll behind the nav bar + if (scrollingView is ViewGroup) { + scrollingView.clipToPadding = false + } + + ViewCompat.setOnApplyWindowInsetsListener(scrollingView) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // Use the calculated floatingNavHeight or current height + val currentNavHeight = if (navView.height > 0) + navView.height + navView.marginBottom + else + floatingNavHeight + + view.updatePadding( + top = bars.top, + bottom = currentNavHeight + ) + insets + } + + // Fix for Articles behind the navbar (Race Condition) + // waits for the NavView, forces one update, then removes itself + if (navView.isLaidOut && navView.height > 0) { + // If NavView is already ready, just trigger the update now + ViewCompat.requestApplyInsets(scrollingView) + } else { + // wait for NavView to be ready + navView.addOnLayoutChangeListener(object : View.OnLayoutChangeListener { + override fun onLayoutChange( + v: View?, left: Int, top: Int, right: Int, bottom: Int, + oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + ) { + // Remove itself + navView.removeOnLayoutChangeListener(this) + + // Force scrolling view to update its padding + ViewCompat.requestApplyInsets(scrollingView) + } + }) + } + } + + // Handle Floating Action Buttons + // Exclude DeviceMapFragment because it manually manages its own Legend FAB positioning + if (f.javaClass.simpleName != "DeviceMapFragment") { + val fabs = findFloatingActionButtons(v) + fabs.forEach { fab -> + val originalFabMargin = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin + + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + val currentNavHeight = if (navView.height > 0) + navView.height + navView.marginBottom + else + floatingNavHeight + + view.updateLayoutParams { + // Ensure FAB floats above the Nav Bar + bottomMargin = originalFabMargin + currentNavHeight + } + insets + } + } + } + } + }, true) + } + + private fun findScrollingView(view: View): View? { + if (view is RecyclerView || view is NestedScrollView || view is ScrollView) { + return view + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + val result = findScrollingView(child) + if (result != null) return result + } + } + return null + } + + private fun findFloatingActionButtons(view: View): List { + val fabs = mutableListOf() + if (view is FloatingActionButton) { + fabs.add(view) + } else if (view is ViewGroup) { + for (i in 0 until view.childCount) { + fabs.addAll(findFloatingActionButtons(view.getChildAt(i))) + } + } + return fabs + } + private fun updateSecureFlag() { if (SharedPrefs.preventScreenshots) { window.setFlags( @@ -130,24 +259,18 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh override fun onResume() { super.onResume() - Timber.d("MainActivity onResume called") - try { - val success = BLEScanner.startBluetoothScan(this.applicationContext) - if (!success) { + if (!BLEScanner.startBluetoothScan(this.applicationContext)) { Timber.e("Failed to start Bluetooth scan.") } } catch (e: Exception) { Timber.e(e, "Error starting Bluetooth scan") } - - Timber.d("Scheduling an immediate background scan onResume of MainActivity") backgroundWorkScheduler.scheduleImmediateBackgroundScan() } override fun onPause() { super.onPause() - Timber.d("MainActivity onPause called") try { BLEScanner.stopBluetoothScan() } catch (e: Exception) { @@ -157,23 +280,17 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh override fun onStart() { super.onStart() - Timber.d("MainActivity onStart called") - - if (ATTrackingDetectionApplication.getCurrentApp().showOnboarding() or !ATTrackingDetectionApplication.getCurrentApp().hasPermissions()) { - ATTrackingDetectionApplication.getCurrentApp().startOnboarding() + val app = ATTrackingDetectionApplication.getCurrentApp() ?: return + if (app.showOnboarding() or !app.hasPermissions()) { + app.startOnboarding() } else { backgroundWorkScheduler.launch() } } - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - Timber.d("MainActivity onWindowFocusChanged: $hasFocus") - } - override fun onDestroy() { sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) - SharedPrefs.lastTimeOpened = dateTime + SharedPrefs.lastTimeOpened = LocalDateTime.now(ZoneOffset.UTC) super.onDestroy() } @@ -182,54 +299,16 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh return navController.navigateUp() || super.onSupportNavigateUp() } - companion object { - private val dateTime = LocalDateTime.now(ZoneOffset.UTC) - - fun configureSystemBars( - activity: AppCompatActivity, - edgeToEdge: Boolean = true, - applyRootPadding: Boolean = true, - ) { - if (edgeToEdge) { - WindowCompat.setDecorFitsSystemWindows(activity.window, false) - } else { - WindowCompat.setDecorFitsSystemWindows(activity.window, true) - } - - val isDarkTheme = Utility.isActualThemeDark(activity) - val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView) - controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller.isAppearanceLightStatusBars = !isDarkTheme - controller.isAppearanceLightNavigationBars = !isDarkTheme - - if (applyRootPadding) { - applySystemBarPadding(activity) - } - } - - fun applySystemBarPadding(activity: AppCompatActivity) { - val content = activity.findViewById(android.R.id.content) - val root = content.getChildAt(0) ?: return - ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets -> - val sysBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(sysBars.left, sysBars.top, sysBars.right, sysBars.bottom) - insets - } - ViewCompat.requestApplyInsets(root) - } - } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - // Check if the changed preference is the advancedMode if (key == "advanced_mode") { - // Update the visibility of the All Devices fragment menu item val navView: BottomNavigationView = findViewById(R.id.main_nav_view) val menu = navView.menu val item = menu.findItem(R.id.navigation_allDevicesFragment) item.isVisible = sharedPreferences?.getBoolean(key, false) ?: false } else if (key == "prevent_screenshots") { - // Update the FLAG_SECURE when the setting changes updateSecureFlag() + } else if (key == "app_theme" || key == "use_dynamic_colors") { + recreate() } } } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/OnboardingActivity.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/OnboardingActivity.kt index 5e2cb6a7..908f6eb7 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/OnboardingActivity.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/OnboardingActivity.kt @@ -7,18 +7,25 @@ import android.content.pm.PackageManager import android.graphics.Color import android.os.Build import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import com.github.appintro.AppIntro import com.github.appintro.AppIntroFragment +import com.github.appintro.model.SliderPage import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.R +import de.seemoo.at_tracking_detection.ui.onboarding.BackgroundLocationFragment import de.seemoo.at_tracking_detection.ui.onboarding.IgnoreBatteryOptimizationFragment +import de.seemoo.at_tracking_detection.ui.onboarding.LocationFragment import de.seemoo.at_tracking_detection.ui.onboarding.ShareDataFragment +import de.seemoo.at_tracking_detection.util.MapUtils import de.seemoo.at_tracking_detection.util.SharedPrefs import de.seemoo.at_tracking_detection.worker.BackgroundWorkScheduler import timber.log.Timber @@ -34,13 +41,13 @@ class OnboardingActivity : AppIntro() { lateinit var backgroundWorkScheduler: BackgroundWorkScheduler var permission: String? = null - private var dialog: AlertDialog? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Prevent Screenshots, if set in settings + enableEdgeToEdge() + if (SharedPrefs.preventScreenshots) { window.setFlags( android.view.WindowManager.LayoutParams.FLAG_SECURE, @@ -48,16 +55,38 @@ class OnboardingActivity : AppIntro() { ) } - MainActivity.configureSystemBars(this, edgeToEdge = true, applyRootPadding = true) - - try { - WindowCompat.getInsetsController(window, window.decorView).show(WindowInsetsCompat.Type.systemBars()) - } catch (e: Exception) { - Timber.w(e, "Failed to disable immersive mode or show system bars") + // Initialize osmdroid configuration + MapUtils.initializeOsmDroidConfiguration(this) + + // Apply System Insets to Content View to prevent navigation bar overlap + ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding( + left = systemBars.left, + right = systemBars.right, + bottom = systemBars.bottom, + top = systemBars.top + ) + insets } + // Setup AppIntro UI Colors (Fixes White on White issue) + val primaryColor = ContextCompat.getColor(this, R.color.md_theme_primary) + val onSurfaceColor = ContextCompat.getColor(this, R.color.md_theme_onSurface) + + setNextArrowColor(onSurfaceColor) + setBackArrowColor(onSurfaceColor) + setColorDoneText(primaryColor) + setColorSkipButton(onSurfaceColor) + setIndicatorColor( + selectedIndicatorColor = primaryColor, + unselectedIndicatorColor = ContextCompat.getColor(this, R.color.md_theme_outline) + ) + setSeparatorColor(Color.TRANSPARENT) + permission = intent.getStringExtra("permission") Timber.d("Onboarding started with: $permission") + if (permission != null) { when (permission) { Manifest.permission.ACCESS_FINE_LOCATION -> locationSlide(1) @@ -74,14 +103,17 @@ class OnboardingActivity : AppIntro() { override fun onResume() { super.onResume() try { - WindowCompat.getInsetsController(window, window.decorView).show(WindowInsetsCompat.Type.systemBars()) + WindowCompat.getInsetsController(window, window.decorView).apply { + show(WindowInsetsCompat.Type.systemBars()) + isAppearanceLightStatusBars = true + isAppearanceLightNavigationBars = true + } } catch (e: Exception) { - Timber.w(e, "Failed to disable immersive mode or show system bars in onResume") + Timber.w(e, "Failed to configure system bars in onResume") } } override fun onDonePressed(currentFragment: Fragment?) { - //Checks which permissions have given to store the default value for location access val locationPermissionState = ContextCompat.checkSelfPermission( applicationContext, @@ -118,10 +150,25 @@ class OnboardingActivity : AppIntro() { handleRequiredPermission(permissionName) } + private fun createM3Slide( + title: String, + description: String, + imageDrawable: Int + ): AppIntroFragment { + val sliderPage = SliderPage() + sliderPage.title = title + sliderPage.description = description + sliderPage.imageDrawable = imageDrawable + sliderPage.backgroundColorRes = R.color.md_theme_background + sliderPage.titleColorRes = R.color.md_theme_onSurface + sliderPage.descriptionColorRes = R.color.md_theme_onSurface + return AppIntroFragment.createInstance(sliderPage) + } + private fun notificationSlide(slideNumber: Int): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { addSlide( - AppIntroFragment.newInstance( + createM3Slide( title = getString(R.string.onboarding_notification_title), description = getString(R.string.onboarding_notification_description), imageDrawable = R.drawable.ic_onboarding_notification @@ -140,7 +187,7 @@ class OnboardingActivity : AppIntro() { private fun scanSlide(slideNumber: Int): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { addSlide( - AppIntroFragment.newInstance( + createM3Slide( title = getString(R.string.onboarding_scan_title), description = getString(R.string.onboarding_scan_description), imageDrawable = R.drawable.ic_signal_searching @@ -159,7 +206,7 @@ class OnboardingActivity : AppIntro() { private fun connectSlide(slideNumber: Int): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { addSlide( - AppIntroFragment.newInstance( + createM3Slide( title = getString(R.string.onboarding_connect_title), description = getString(R.string.onboarding_connect_description), imageDrawable = R.drawable.ic_play_sound @@ -176,42 +223,13 @@ class OnboardingActivity : AppIntro() { } private fun locationSlide(slideNumber: Int): Boolean { - addSlide( - AppIntroFragment.newInstance( - title = getString(R.string.location_permission_title), - description = getString(R.string.location_permission_message), - imageDrawable = R.drawable.ic_location - ) - ) - - askForPermissions( - permissions = arrayOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION - ), - required = true, - slideNumber = slideNumber - ) + addSlide(LocationFragment.newInstance()) return true } private fun backgroundLocationSlide(slideNumber: Int): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - addSlide( - AppIntroFragment.newInstance( - title = getString(R.string.onboarding_4_title), - description = getString(R.string.onboarding_4_description), - imageDrawable = R.drawable.img_tracking_map - ) - ) - - askForPermissions( - permissions = arrayOf( - Manifest.permission.ACCESS_BACKGROUND_LOCATION - ), - required = false, - slideNumber = slideNumber - ) + addSlide(BackgroundLocationFragment.newInstance()) return true } return false @@ -224,13 +242,8 @@ class OnboardingActivity : AppIntro() { var slideNumber = 1 - setIndicatorColor( - selectedIndicatorColor = Color.LTGRAY, - unselectedIndicatorColor = Color.DKGRAY - ) - addSlide( - AppIntroFragment.newInstance( + createM3Slide( title = getString(R.string.app_name), description = getString(R.string.onboarding_1_description), imageDrawable = R.mipmap.ic_launcher @@ -252,8 +265,9 @@ class OnboardingActivity : AppIntro() { addSlide(ShareDataFragment.newInstance()) addSlide( - AppIntroFragment.newInstance( + createM3Slide( title = getString(R.string.onboarding_5_title), + description = "", // Empty description if not needed imageDrawable = R.drawable.ic_security_on ) ) @@ -278,4 +292,4 @@ class OnboardingActivity : AppIntro() { .show() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/TrackingNotificationActivity.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/TrackingNotificationActivity.kt index e61716fd..e9e6152e 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/TrackingNotificationActivity.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/TrackingNotificationActivity.kt @@ -2,17 +2,31 @@ package de.seemoo.at_tracking_detection.ui import android.content.Intent import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ScrollView import androidx.activity.OnBackPressedCallback +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.database.repository.NotificationRepository import de.seemoo.at_tracking_detection.ui.tracking.TrackingFragment import de.seemoo.at_tracking_detection.ui.tracking.TrackingFragmentArgs +import de.seemoo.at_tracking_detection.util.MapUtils import de.seemoo.at_tracking_detection.util.SharedPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -30,6 +44,8 @@ class TrackingNotificationActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() + // Prevent Screenshots, if set in settings if (SharedPrefs.preventScreenshots) { window.setFlags( @@ -38,11 +54,14 @@ class TrackingNotificationActivity : AppCompatActivity() { ) } - // For notification entry: disable edge-to-edge so content area automatically respects system bars - MainActivity.configureSystemBars(this, edgeToEdge = true, applyRootPadding = true) + // Initialize osmdroid configuration + MapUtils.initializeOsmDroidConfiguration(this) setContentView(R.layout.activity_tracking) + // Automagically handle Padding for Content inside Fragments to prevent cut-offs + setupFragmentContentPadding() + val navHostFragment = supportFragmentManager.findFragmentById(R.id.tracking_host_fragment) as NavHostFragment navController = navHostFragment.navController @@ -60,9 +79,78 @@ class TrackingNotificationActivity : AppCompatActivity() { }) } + /** + * Similar to MainActivity but without BottomNavigation + */ + private fun setupFragmentContentPadding() { + supportFragmentManager.registerFragmentLifecycleCallbacks(object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) { + + // Handle Scrolling Content (RecyclerView, etc.) + val scrollingView = findScrollingView(v) ?: v + + if (scrollingView is ViewGroup) { + scrollingView.clipToPadding = false + } + + ViewCompat.setOnApplyWindowInsetsListener(scrollingView) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // Apply padding for Status Bar (top) and Navigation Bar (bottom) + view.updatePadding( + top = bars.top, + bottom = bars.bottom + ) + insets + } + + // Handle Floating Action Buttons + val fabs = findFloatingActionButtons(v) + fabs.forEach { fab -> + val originalFabMargin = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin + + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + view.updateLayoutParams { + // Ensure FAB floats above the system navigation bar + bottomMargin = originalFabMargin + bars.bottom + } + insets + } + } + } + }, true) + } + + private fun findScrollingView(view: View): View? { + if (view is RecyclerView || view is NestedScrollView || view is ScrollView) { + return view + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + val result = findScrollingView(child) + if (result != null) return result + } + } + return null + } + + private fun findFloatingActionButtons(view: View): List { + val fabs = mutableListOf() + if (view is FloatingActionButton) { + fabs.add(view) + } else if (view is ViewGroup) { + for (i in 0 until view.childCount) { + fabs.addAll(findFloatingActionButtons(view.getChildAt(i))) + } + } + return fabs + } + override fun onResume() { super.onResume() - MainActivity.configureSystemBars(this, edgeToEdge = false, applyRootPadding = false) val fragment = supportFragmentManager.findFragmentById(R.id.tracking_host_fragment) if (fragment is NavHostFragment) { val trackingFragment = fragment.childFragmentManager.primaryNavigationFragment diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt index f8a3e029..e3ab4191 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt @@ -14,6 +14,7 @@ data class Article( val readingTime: Int, val previewText: String, val cardColor: String, + val textColor: String? = null, val preview_image: String, // This has to be named like this because of the file format val filename: String ) @@ -79,11 +80,12 @@ fun loadArticlesJson(context: Context = ATTrackingDetectionApplication.getAppCon // Build a minimal fallback inline so UI still shows something. val offlineTitle = context.getString(R.string.article_offline_header) val offlineText = context.getString(R.string.article_offline_text) - """{"article0":{"title":"$offlineTitle","author":"System","readingTime":0,"previewText":"$offlineText","cardColor":"blue_card_background","preview_image":"","filename":""}}""" + // MD3 Update: Use secondaryContainer instead of blue_card_background + """{"article0":{"title":"$offlineTitle","author":"System","readingTime":0,"previewText":"$offlineText","cardColor":"md_theme_secondaryContainer","preview_image":"","filename":""}}""" } } } fun getLocalImageUri(imageFilename: String): String { return "file:///android_asset/articles/$imageFilename" -} +} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt index de932265..c7319c2e 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt @@ -6,6 +6,8 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import de.seemoo.at_tracking_detection.R diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardFragment.kt deleted file mode 100644 index 03ea3203..00000000 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -package de.seemoo.at_tracking_detection.ui.dashboard - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import dagger.hilt.android.AndroidEntryPoint -import de.seemoo.at_tracking_detection.R -import de.seemoo.at_tracking_detection.databinding.FragmentDashboardBinding -import timber.log.Timber -import java.time.LocalDateTime -import java.time.ZoneOffset - -@AndroidEntryPoint -class DashboardFragment : Fragment() { - - private val dashboardViewModel: DashboardViewModel by viewModels() - - private lateinit var binding: FragmentDashboardBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = DataBindingUtil.inflate( - inflater, - R.layout.fragment_dashboard, - container, - false - ) - binding.lifecycleOwner = viewLifecycleOwner - binding.vm = dashboardViewModel - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val lineGraphChart = view.findViewById(R.id.line_graph) - - dashboardViewModel.getBeaconHistory(dateTime.minusDays(HISTORY_LENGTH)) - .observe(viewLifecycleOwner) { beaconList -> - Timber.d("beacon list size: ${beaconList.size}") - val dataPoints = mutableListOf() - (0..HISTORY_LENGTH).forEach { counter -> - val dayOfMonth = dateTime.minusDays(counter).dayOfMonth - val amount = - beaconList.filter { it.receivedAt.dayOfMonth == dayOfMonth } - .distinctBy { it.deviceAddress }.size - dataPoints.add(counter.toInt(), DataPoint(amount.toFloat())) - } - lineGraphChart.addDataPoints(dataPoints.reversed()) - Timber.d("Added ${dataPoints.size} new data points to the graph chart!") - } - - } - - companion object { - private val dateTime = LocalDateTime.now(ZoneOffset.UTC) - private const val HISTORY_LENGTH = 14L - } - -} diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardRiskFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardRiskFragment.kt index aaf1fe7f..86ad42ac 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardRiskFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardRiskFragment.kt @@ -2,6 +2,7 @@ package de.seemoo.at_tracking_detection.ui.dashboard import android.annotation.SuppressLint import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -10,6 +11,9 @@ import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -78,7 +82,9 @@ class DashboardRiskFragment : Fragment() { val progressBar = view.findViewById(R.id.loading_progress_bar) lifecycleScope.launch(Dispatchers.IO) { - progressBar.visibility = View.VISIBLE + withContext(Dispatchers.Main) { + progressBar.visibility = View.VISIBLE + } val articlesJSON = loadArticlesJson(requireContext()) Timber.d("Articles JSON: %s", articlesJSON) @@ -87,26 +93,15 @@ class DashboardRiskFragment : Fragment() { var articles = parseArticles(articlesJSON) Timber.d("Number of Articles: %s", articles.size) - // Show an error message if the Bluetooth scan bug on Android 15 is detected -// if (Build.VERSION.SDK_INT >= 35 && SharedPrefs.showSamsungAndroid15BugNotification) { -// val bugArticle = Article( -// title = getString(R.string.samsung_bug_notification_title), -// author = "System", -// readingTime = 0, -// previewText = getString(R.string.samsung_bug_notification_text), -// cardColor = "warning_light_red", -// preview_image = "", -// filename = "" -// ) -// articles = listOf(bugArticle) + articles -// } else + // Error articles if (SharedPrefs.showGenericBluetoothBugNotification || SharedPrefs.showSamsungAndroid15BugNotification) { val bugArticle = Article( title = getString(R.string.samsung_bug_notification_title), author = "System", readingTime = 0, previewText = getString(R.string.generic_bluetooth_bug_notification_text), - cardColor = "warning_light_red", + cardColor = "md_theme_error", + textColor = "md_theme_onError", preview_image = "", filename = "" ) @@ -119,7 +114,8 @@ class DashboardRiskFragment : Fragment() { author = "System", readingTime = 0, previewText = getString(R.string.notification_permission_missing_text), - cardColor = "warning_light_red", + cardColor = "md_theme_error", + textColor = "md_theme_onError", preview_image = "", filename = "" ) @@ -132,7 +128,8 @@ class DashboardRiskFragment : Fragment() { author = "System", readingTime = 0, previewText = getString(R.string.background_location_permission_missing_text), - cardColor = "warning_light_red", + cardColor = "md_theme_error", + textColor = "md_theme_onError", preview_image = "", filename = "" ) @@ -145,13 +142,21 @@ class DashboardRiskFragment : Fragment() { articleCardsLinearLayout.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) for (article in articles) { - val articleCard = MaterialCardView(context) + val articleCard = MaterialCardView(requireContext()) + + // Standard Corner Radius for MD3 Cards is 12dp + val radiusPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12f, resources.displayMetrics) + articleCard.radius = radiusPx + // Standard Elevation for Filled Cards is 0dp (flat) + articleCard.cardElevation = 0f + articleCard.strokeWidth = 0 val layout = LayoutInflater.from(context).inflate(R.layout.include_article_card, null) val textViewTitle = layout.findViewById(R.id.card_title) val textViewPreviewText = layout.findViewById(R.id.card_text_preview) val imageViewPreview = layout.findViewById(R.id.preview_image) val materialCard = layout.findViewById(R.id.material_card) + val dismissButton = layout.findViewById(R.id.dismiss_button) textViewTitle.text = article.title if (article.previewText.isNotEmpty()){ @@ -160,8 +165,31 @@ class DashboardRiskFragment : Fragment() { textViewPreviewText.visibility = View.GONE } - val colorResourceId = resources.getIdentifier(article.cardColor, "color", context?.packageName) - materialCard.setCardBackgroundColor(resources.getColor(colorResourceId, null)) + // Safe Color Resolution for MD3 Migration + val colorResId = resolveCardColor(article.cardColor) + val backgroundColor = ContextCompat.getColor(requireContext(), colorResId) + materialCard.setCardBackgroundColor(backgroundColor) + + // Ensure Text Color is Dark (onSurface) for Pastel Backgrounds + val textColor = ContextCompat.getColor(requireContext(), resolveTextColor(article.textColor)) + textViewTitle.setTextColor(textColor) + textViewPreviewText.setTextColor(textColor) + dismissButton.setColorFilter(textColor) + + // Show dismiss button for error articles (aka articles without filenames) + if (article.filename.isEmpty()) { + dismissButton.visibility = View.VISIBLE + dismissButton.setOnClickListener { + articleCardsLinearLayout.removeView(articleCard) + + if (article.title == getString(R.string.samsung_bug_notification_title) && SharedPrefs.showGenericBluetoothBugNotification) { + SharedPrefs.showGenericBluetoothBugNotification = false + } + if (article.title == getString(R.string.samsung_bug_notification_title) && SharedPrefs.showSamsungAndroid15BugNotification) { + SharedPrefs.showSamsungAndroid15BugNotification = false + } + } + } articleCard.addView(layout) Timber.tag("CardAdded").d("Article card added: %s", article.title) @@ -211,16 +239,28 @@ class DashboardRiskFragment : Fragment() { // checkAndShowReview() } - // Google Play Review Controller: Only active in Google Play builds -// private fun checkAndShowReview() { -// Timber.d("Checking if review should be shown") -// if (BuildConfig.DEBUG) { -// reviewController.debugReviewStatus() -// } -// reviewController.requestReviewDialog(requireActivity()) { -// Timber.d("Review dialog request completed") -// } -// } + /** + * Maps legacy color names from JSON/Article objects to new Material 3 colors. + */ + private fun resolveCardColor(colorName: String): Int { + return when (colorName) { + "warning_light_red" -> R.color.md_theme_error + "risk_high" -> R.color.risk_high + "blue_card_background", "md_theme_secondaryContainer" -> R.color.md_theme_secondaryContainer + "gray_card_background" -> R.color.md_theme_surfaceVariant + else -> { + // Try to find resource by name, fallback to secondaryContainer + val resId = resources.getIdentifier(colorName, "color", requireContext().packageName) + if (resId != 0) resId else R.color.md_theme_secondaryContainer + } + } + } + + private fun resolveTextColor(colorName: String?): Int { + if (colorName.isNullOrBlank()) return R.color.md_theme_onSurface + val resId = resources.getIdentifier(colorName, "color", requireContext().packageName) + return if (resId != 0) resId else R.color.md_theme_onSurface + } override fun onStart() { super.onStart() diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardViewModel.kt deleted file mode 100644 index 3d35eac0..00000000 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DashboardViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package de.seemoo.at_tracking_detection.ui.dashboard - -import android.content.SharedPreferences -import androidx.lifecycle.* -import androidx.work.WorkInfo -import dagger.hilt.android.lifecycle.HiltViewModel -import de.seemoo.at_tracking_detection.database.repository.BeaconRepository -import de.seemoo.at_tracking_detection.database.repository.DeviceRepository -import de.seemoo.at_tracking_detection.database.repository.NotificationRepository -import de.seemoo.at_tracking_detection.database.models.Beacon -import de.seemoo.at_tracking_detection.util.SharedPrefs -import de.seemoo.at_tracking_detection.worker.BackgroundWorkScheduler -import de.seemoo.at_tracking_detection.worker.WorkerConstants -import kotlinx.coroutines.flow.map -import timber.log.Timber -import java.time.LocalDateTime -import java.time.ZoneOffset -import javax.inject.Inject - -@HiltViewModel -class DashboardViewModel @Inject constructor( - val beaconRepository: BeaconRepository, - val notificationRepository: NotificationRepository, - val deviceRepository: DeviceRepository, - private val sharedPreferences: SharedPreferences, - val backgroundWorkScheduler: BackgroundWorkScheduler -) : ViewModel() { - - private var lastScan: LocalDateTime - - private var lastTimeOpened: LocalDateTime - - private var dateTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) - - private var sharedPreferencesListener: SharedPreferences.OnSharedPreferenceChangeListener = - SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - when (key) { - "last_scan" -> lastScan = LocalDateTime.parse( - sharedPreferences.getString( - "last_scan", - dateTime.minusMinutes(15).toString() - ) - ) - } - } - - init { - lastScan = LocalDateTime.parse( - sharedPreferences.getString( - "last_scan", - dateTime.toString() - ) - ) - lastTimeOpened = SharedPrefs.lastTimeOpened ?: dateTime - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferencesListener) - Timber.d("last scan: $lastScan") - } - - val isMapLoading = MutableLiveData(false) - - val isScanning: LiveData = - backgroundWorkScheduler.getState(WorkerConstants.PERIODIC_SCAN_WORKER).map { - it == WorkInfo.State.RUNNING - } - - val totalLocationsTrackedCount: LiveData = beaconRepository.locationCount.asLiveData() - - val totalLocationsCountChange: LiveData = - beaconRepository.totalLocationCountChange(lastTimeOpened).asLiveData() - - val totalDeviceCount: LiveData = deviceRepository.totalCount.asLiveData() - val totalDeviceCountChange: LiveData = - deviceRepository.totalDeviceCountChange(lastTimeOpened).asLiveData() - - val currentlyMonitoredDevices: LiveData = - deviceRepository.devicesCurrentlyMonitored(lastScan).asLiveData() - - val hideMap: LiveData = - beaconRepository.totalCount.map { it == 0 }.asLiveData() - - val totalAlertCount: LiveData = notificationRepository.totalCount.asLiveData() - val totalAlertCountChange: LiveData = - notificationRepository.totalCountChange(lastTimeOpened).asLiveData() - - val totalFalseAlarmCount: LiveData = - notificationRepository.totalFalseAlarmCount.asLiveData() - val totalFalseAlarmCountChange: LiveData = - notificationRepository.totalFalseAlarmCountChange(lastTimeOpened).asLiveData() - - fun getBeaconHistory(since: LocalDateTime): LiveData> = - beaconRepository.getBeaconsSince(since).asLiveData() -} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt index 1432b655..9114a2db 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt @@ -1,6 +1,8 @@ package de.seemoo.at_tracking_detection.ui.dashboard import android.Manifest +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities @@ -8,19 +10,23 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.marginBottom +import androidx.core.view.updateLayoutParams import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.database.models.Location import de.seemoo.at_tracking_detection.databinding.FragmentDeviceMapBinding +import de.seemoo.at_tracking_detection.util.MapUtils import de.seemoo.at_tracking_detection.util.Utility import de.seemoo.at_tracking_detection.util.risk.RiskLevelEvaluator import kotlinx.coroutines.launch @@ -35,7 +41,8 @@ class DeviceMapFragment : Fragment() { private lateinit var binding: FragmentDeviceMapBinding private var deviceAddress: String? = null - private var isLegendExpanded = false + + private var isLegendVisible = false override fun onCreateView( inflater: LayoutInflater, @@ -64,51 +71,46 @@ class DeviceMapFragment : Fragment() { setTitle() - binding.legendContent.visibility = View.INVISIBLE - binding.legendContainer.post { - val widthSpec = View.MeasureSpec.makeMeasureSpec(binding.legendContainer.width - binding.legendContainer.paddingStart - binding.legendContainer.paddingEnd, View.MeasureSpec.EXACTLY) - val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) - binding.legendContent.measure(widthSpec, heightSpec) - val contentHeight = binding.legendContent.measuredHeight - // Shift container down by content height so only header remains visible - binding.legendContainer.translationY = contentHeight.toFloat() - isLegendExpanded = false - // Set arrow to up - binding.legendCollapseButton.rotation = 270f - binding.legendContent.visibility = View.INVISIBLE - - val footerText = if (deviceAddress.isNullOrEmpty()) { - getString(R.string.map_footer_all_devices, RiskLevelEvaluator.RELEVANT_DAYS_RISK_LEVEL) - } else { - getString(R.string.map_footer_device) - } - binding.legendFooterText.text = footerText + // Setup initial state for legend + binding.legendFab.visibility = View.VISIBLE + binding.legendContainer.visibility = View.INVISIBLE + isLegendVisible = false + + val footerText = if (deviceAddress.isNullOrEmpty()) { + getString(R.string.map_footer_all_devices, RiskLevelEvaluator.RELEVANT_DAYS_RISK_LEVEL) + } else { + getString(R.string.map_footer_device) } + binding.legendFooterText.text = footerText + + setupLegendInteractions() - // Setup legend toggle functionality - setupLegendToggle() + // Handle Edge-to-Edge and Insets + handleWindowInsets() + // Connectivity Check val connectivityManager = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) if (networkCapabilities == null || !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { viewModel.isMapLoading.postValue(false) viewModel.hideMapShowNoInternetInstead.postValue(true) - + binding.legendFab.visibility = View.GONE return } ViewCompat.setTranslationZ(view, 100f) val map: MapView = view.findViewById(R.id.map) - Utility.basicMapSetup(map) + MapUtils.basicMapSetup(map) Utility.checkAndRequestPermission(Manifest.permission.ACCESS_FINE_LOCATION) viewModel.isMapLoading.postValue(true) - // Utility.enableMyLocationOverlay(map) // This enables the blue location dot on the map + // MapUtils.enableMyLocationOverlay(map) // This enables the blue location dot on the map lifecycleScope.launch { - val locationRepository = ATTrackingDetectionApplication.getCurrentApp().locationRepository + val locationRepository = ATTrackingDetectionApplication.getCurrentApp()?.locationRepository + ?: error("ATTrackingDetectionApplication not initialized") val relevantTrackingDate = RiskLevelEvaluator.relevantTrackingDateForRiskCalculation val locationList: List = if (!deviceAddress.isNullOrEmpty()) { locationRepository.getLocationsForBeacon(deviceAddress!!) @@ -118,68 +120,162 @@ class DeviceMapFragment : Fragment() { } try { - Utility.setGeoPointsFromListOfLocations(locationList, map) + // Only show device info popup when viewing all devices (aka deviceAddress is null or empty) + val showDeviceInfoOnClick = deviceAddress.isNullOrEmpty() + MapUtils.setGeoPointsFromListOfLocations( + locationList, + map, + showDeviceInfoOnClick, + onMarkerClick = { deviceAddress -> + // Navigate to tracking fragment when marker info window is clicked + val action = DeviceMapFragmentDirections.actionDeviceMapFragmentToTrackingFragment(deviceAddress) + findNavController().navigate(action) + }, + onMoreTrackersClick = { locationId -> + // Navigate to devices fragment with location filter + val action = DeviceMapFragmentDirections.actionDeviceMapFragmentToDevicesFound( + showDevicesFound = true, + showAllDevices = true, + locationId = locationId + ) + findNavController().navigate(action) + }, + onMapClick = { + // Close legend if map is clicked + if (isLegendVisible) { + hideLegend() + } + } + ) } finally { viewModel.isMapLoading.postValue(false) } } } - private fun setupLegendToggle() { - // Toggle when tapping header title or arrow - binding.legendHeaderTitle.setOnClickListener { - if (isLegendExpanded) hideLegend() else showLegend() + private fun handleWindowInsets() { + // Prevent double padding on root view from MainActivity's global listener + binding.root.post { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + insets + } } - binding.legendCollapseButton.setOnClickListener { - if (isLegendExpanded) hideLegend() else showLegend() + + val legendCard = binding.legendContainer + val fab = binding.legendFab + + // Define a standard margin (16dp) + val standardMargin = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_DIP, + 16f, + resources.displayMetrics + ).toInt() + + // Apply ONLY the standard margin. + ViewCompat.setOnApplyWindowInsetsListener(legendCard) { view, insets -> + view.updateLayoutParams { + bottomMargin = standardMargin + } + insets } - } - private fun showLegend() { - if (isLegendExpanded) return - isLegendExpanded = true + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + view.updateLayoutParams { + bottomMargin = standardMargin + } + insets + } + } - binding.legendContent.visibility = View.VISIBLE + private fun setupLegendInteractions() { + binding.legendFab.setOnClickListener { showLegend() } + binding.legendCollapseButton.setOnClickListener { hideLegend() } + binding.scrimOverlay.setOnClickListener { hideLegend() } + } - // Animate the whole container upward to reveal content. translationY -> 0 - binding.legendContainer.animate() - .translationY(0f) - .setDuration(300) - .setInterpolator(AccelerateDecelerateInterpolator()) + private fun showLegend() { + if (isLegendVisible) return + isLegendVisible = true + + // Material3 emphasized interpolator for smooth motion + val emphasizedInterpolator = android.view.animation.PathInterpolator(0.2f, 0f, 0f, 1f) + + // Show scrim overlay + binding.scrimOverlay.visibility = View.VISIBLE + binding.scrimOverlay.animate() + .alpha(0.32f) + .setDuration(200) + .setInterpolator(emphasizedInterpolator) .start() - // Rotate arrow to point down - binding.legendCollapseButton.animate() - .rotation(90f) - .setDuration(300) + // Animation: Fade out FAB + binding.legendFab.animate() + .alpha(0f) + .scaleX(0.8f) + .scaleY(0.8f) + .setDuration(200) + .setInterpolator(emphasizedInterpolator) + .withEndAction { binding.legendFab.visibility = View.INVISIBLE } .start() + + // Animation: Reveal Card with slide-up effect + binding.legendContainer.apply { + visibility = View.VISIBLE + alpha = 0f + translationY = 100f // Slide up from bottom + animate() + .alpha(1f) + .translationY(0f) + .setDuration(300) + .setInterpolator(emphasizedInterpolator) + .setListener(null) + .start() + } } private fun hideLegend() { - if (!isLegendExpanded) return - isLegendExpanded = false - - // Re-measure in case layout changed - val widthSpec = View.MeasureSpec.makeMeasureSpec(binding.legendContainer.width - binding.legendContainer.paddingStart - binding.legendContainer.paddingEnd, View.MeasureSpec.EXACTLY) - val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) - binding.legendContent.measure(widthSpec, heightSpec) - val contentHeight = binding.legendContent.measuredHeight + if (!isLegendVisible) return + isLegendVisible = false + + // Material3 emphasized interpolator + val emphasizedInterpolator = android.view.animation.PathInterpolator(0.2f, 0f, 0f, 1f) + + // Hide scrim overlay + binding.scrimOverlay.animate() + .alpha(0f) + .setDuration(250) + .setInterpolator(emphasizedInterpolator) + .withEndAction { binding.scrimOverlay.visibility = View.GONE } + .start() - // Animate container back down so only header is visible + // Animation: Hide Card with slide-down effect binding.legendContainer.animate() - .translationY(contentHeight.toFloat()) - .setDuration(300) - .setInterpolator(AccelerateDecelerateInterpolator()) - .withEndAction { - binding.legendContent.visibility = View.INVISIBLE - } + .alpha(0f) + .translationY(100f) // Slide down + .setDuration(250) + .setInterpolator(emphasizedInterpolator) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.legendContainer.visibility = View.INVISIBLE + binding.legendContainer.translationY = 0f // Reset for next show + } + }) .start() - // Rotate arrow to point up - binding.legendCollapseButton.animate() - .rotation(270f) - .setDuration(300) - .start() + // Animation: Fade in FAB + binding.legendFab.apply { + visibility = View.VISIBLE + alpha = 0f + scaleX = 0.8f + scaleY = 0.8f + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(250) + .setInterpolator(emphasizedInterpolator) + .start() + } } override fun onResume() { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskCardViewModel.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskCardViewModel.kt index 14514247..42113510 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskCardViewModel.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskCardViewModel.kt @@ -26,9 +26,10 @@ class RiskCardViewModel @Inject constructor( private val sharedPreferences: SharedPreferences ) : ViewModel() { - var riskLevel: String = "No risk" - var riskColor: Int = 0 - var showLastDetection: Boolean = true + var riskLevel: MutableLiveData = MutableLiveData("No risk") + var riskColor: MutableLiveData = MutableLiveData(R.color.risk_low) + var showLastDetection: MutableLiveData = MutableLiveData(true) + var clickable: Boolean = true var trackersFoundModel: MutableLiveData = MutableLiveData() var lastUpdateModel: MutableLiveData = MutableLiveData() @@ -42,15 +43,15 @@ class RiskCardViewModel @Inject constructor( "last_scan" -> { lastScan = SharedPrefs.lastScanDate updateLastUpdateModel() - } } + } when (key) { "dismiss_survey_information" -> { dismissSurveyInformation.postValue(SharedPrefs.dismissSurveyInformation) } } - } + } init { @@ -62,11 +63,11 @@ class RiskCardViewModel @Inject constructor( val lastScanString = if (lastScan != null) { DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).format(lastScan) - }else { + } else { ATTrackingDetectionApplication.getAppContext().getString(R.string.none) } - lastUpdateModel.postValue ( RiskRowViewModel( + lastUpdateModel.postValue(RiskRowViewModel( context.getString(R.string.last_scan_info, lastScanString), ContextCompat.getDrawable(context, R.drawable.ic_last_update)!! )) @@ -88,8 +89,8 @@ class RiskCardViewModel @Inject constructor( when (riskLevelEvaluator.evaluateRiskLevel()) { RiskLevel.LOW -> { - riskLevel = context.getString(R.string.risk_level_low) - riskColor = ContextCompat.getColor(context, R.color.risk_low) + riskLevel.postValue(context.getString(R.string.risk_level_low)) + riskColor.postValue(R.color.risk_low) trackersFoundModel.postValue(RiskRowViewModel( context.getString(R.string.no_trackers_found, earliestTrackingDateString), @@ -100,12 +101,11 @@ class RiskCardViewModel @Inject constructor( ContextCompat.getDrawable(context, R.drawable.ic_clock)!! )) - showLastDetection = false - + showLastDetection.postValue(false) } RiskLevel.MEDIUM -> { - riskLevel = context.getString(R.string.risk_level_medium) - riskColor = ContextCompat.getColor(context, R.color.risk_medium) + riskLevel.postValue(context.getString(R.string.risk_level_medium)) + riskColor.postValue(R.color.risk_medium) trackersFoundModel.postValue(RiskRowViewModel( context.getString( @@ -121,13 +121,12 @@ class RiskCardViewModel @Inject constructor( ContextCompat.getDrawable(context, R.drawable.ic_clock)!! )) - + showLastDetection.postValue(true) } else -> { - //High risk - riskLevel = context.getString(R.string.risk_level_high) - riskColor = ContextCompat.getColor(context, R.color.risk_high) - + // High risk + riskLevel.postValue(context.getString(R.string.risk_level_high)) + riskColor.postValue(R.color.risk_high) trackersFoundModel.postValue(RiskRowViewModel( context.getString( @@ -142,6 +141,8 @@ class RiskCardViewModel @Inject constructor( context.getString(R.string.last_discovery, lastDiscoveryDateString), ContextCompat.getDrawable(context, R.drawable.ic_clock)!! )) + + showLastDetection.postValue(true) } } } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailFragment.kt index 616097ea..555ae6a8 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailFragment.kt @@ -4,6 +4,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -48,28 +50,19 @@ class RiskDetailFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - //Not sure if we can link to Apple's content. -// val deactivateAirTagCard: MaterialCardView = view.findViewById(R.id.find_airtag_card) - -// deactivateAirTagCard.setOnClickListener { -// val intent = Intent( -// Intent.ACTION_VIEW, -// Uri.parse("https://support.apple.com/en-us/HT212227") -// ) -// startActivity(intent) -// } - view.findViewById(R.id.card_trackers_found).setOnClickListener { val directions: NavDirections = - RiskDetailFragmentDirections.actionRiskDetailFragmentToNavigationDevices(true) + RiskDetailFragmentDirections.actionRiskDetailFragmentToNavigationDevices( + showDevicesFound = true, + preselectNotifiedFilter = "INCLUDING", + preselectIgnoredFilter = "EXCLUDING" + ) findNavController().navigate(directions) } view.findViewById(R.id.card_tracked_locations).setOnClickListener { val directions = - RiskDetailFragmentDirections.actionRiskDetailFragmentToDeviceMapFragment( - - ) + RiskDetailFragmentDirections.actionRiskDetailFragmentToDeviceMapFragment() findNavController().navigate(directions) } @@ -83,11 +76,5 @@ class RiskDetailFragment : Fragment() { } else { view.findViewById(R.id.card_devices_found).visibility = View.VISIBLE } - -// view.findViewById(R.id.card_beacons_found).setOnClickListener { -// val directions = -// RiskDetailFragmentDirections.actionRiskDetailFragmentToDeviceMapFragment() -// findNavController().navigate(directions) -// } } } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailViewModel.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailViewModel.kt index f6c8fc91..ed78190f 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailViewModel.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RiskDetailViewModel.kt @@ -1,16 +1,14 @@ package de.seemoo.at_tracking_detection.ui.dashboard -import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import dagger.hilt.android.lifecycle.HiltViewModel -import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.R +import de.seemoo.at_tracking_detection.database.models.device.BaseDevice import de.seemoo.at_tracking_detection.database.repository.BeaconRepository import de.seemoo.at_tracking_detection.database.repository.DeviceRepository -import de.seemoo.at_tracking_detection.database.models.device.BaseDevice import de.seemoo.at_tracking_detection.database.repository.LocationRepository import de.seemoo.at_tracking_detection.database.repository.ScanRepository import de.seemoo.at_tracking_detection.util.risk.RiskLevel @@ -36,6 +34,7 @@ class RiskDetailViewModel @Inject constructor( } var riskColor: Int + val numberOfTrackersFound = deviceRepository.trackingDevicesNotIgnoredSinceCount(RiskLevelEvaluator.relevantTrackingDateForRiskCalculation).asLiveData() val totalLocationsTrackedCount = locationRepository.locationsSinceCount(relevantDate).asLiveData() @@ -57,12 +56,11 @@ class RiskDetailViewModel @Inject constructor( } init { - val context = ATTrackingDetectionApplication.getAppContext() riskColor = when (riskLevelEvaluator.evaluateRiskLevel()) { - RiskLevel.LOW -> ContextCompat.getColor(context, R.color.risk_low) - RiskLevel.MEDIUM -> ContextCompat.getColor(context, R.color.risk_medium) - RiskLevel.HIGH -> ContextCompat.getColor(context, R.color.risk_high) + RiskLevel.LOW -> R.color.risk_low + RiskLevel.MEDIUM -> R.color.risk_medium + RiskLevel.HIGH -> R.color.risk_high } - Timber.d("Risk Color: $riskColor") + Timber.d("Risk Color ID: $riskColor") } } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesFragment.kt index 714846c9..08d3bf49 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesFragment.kt @@ -1,12 +1,12 @@ package de.seemoo.at_tracking_detection.ui.devices import android.os.Bundle -import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController @@ -53,54 +53,93 @@ class AllDevicesFragment : Fragment() { view.findViewById(R.id.tracker_devices_card).setOnClickListener { val directions: NavDirections = - AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true) + AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + preselectNotifiedFilter = "INCLUDING", + preselectIgnoredFilter = "EXCLUDING" + ) findNavController().navigate(directions) } view.findViewById(R.id.all_devices_card).setOnClickListener { val directions: NavDirections = - AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true) + AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true + ) findNavController().navigate(directions) } view.findViewById(R.id.ignored_devices_card).setOnClickListener { val directions: NavDirections = - AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToNavigationIgnoredDevicesFragment() + AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + preselectIgnoredFilter = "INCLUDING", + preselectRemoveDateRange = true + ) findNavController().navigate(directions) } view.findViewById(R.id.airtags_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.AIRTAG) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.AIRTAG + ) findNavController().navigate(directions) } view.findViewById(R.id.findmy_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.FIND_MY) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.FIND_MY + ) findNavController().navigate(directions) } view.findViewById(R.id.tiles_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.TILE) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.TILE + ) findNavController().navigate(directions) } view.findViewById(R.id.chipolos_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.CHIPOLO) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.CHIPOLO + ) findNavController().navigate(directions) } view.findViewById(R.id.pebblebees_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.PEBBLEBEE) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.PEBBLEBEE + ) findNavController().navigate(directions) } view.findViewById(R.id.smarttags_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.SAMSUNG_TRACKER) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.SAMSUNG_TRACKER + ) findNavController().navigate(directions) } view.findViewById(R.id.google_found_card).setOnClickListener { - val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2(showDevicesFound = true, showAllDevices = true, deviceType = DeviceType.GOOGLE_FIND_MY_NETWORK) + val directions = AllDevicesFragmentDirections.actionNavigationAllDevicesFragmentToDevicesFound2( + showDevicesFound = true, + showAllDevices = true, + deviceType = DeviceType.GOOGLE_FIND_MY_NETWORK + ) findNavController().navigate(directions) } } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesViewModel.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesViewModel.kt index 2bf43541..9871be69 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesViewModel.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/AllDevicesViewModel.kt @@ -1,10 +1,8 @@ package de.seemoo.at_tracking_detection.ui.devices -import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import dagger.hilt.android.lifecycle.HiltViewModel -import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.database.models.device.DeviceType import de.seemoo.at_tracking_detection.database.repository.DeviceRepository @@ -30,14 +28,9 @@ class AllDevicesViewModel @Inject constructor( val countPebblebee = deviceRepository.countForDeviceType(DeviceType.PEBBLEBEE).asLiveData() val countSmartTag = deviceRepository.countForDeviceType(DeviceType.SAMSUNG_TRACKER).asLiveData() - var riskColor: Int - - init { - val context = ATTrackingDetectionApplication.getAppContext() - riskColor = when (riskLevelEvaluator.evaluateRiskLevel()) { - RiskLevel.LOW -> ContextCompat.getColor(context, R.color.risk_low) - RiskLevel.MEDIUM -> ContextCompat.getColor(context, R.color.risk_medium) - RiskLevel.HIGH -> ContextCompat.getColor(context, R.color.risk_high) - } + var riskColor: Int = when (riskLevelEvaluator.evaluateRiskLevel()) { + RiskLevel.LOW -> R.color.risk_low + RiskLevel.MEDIUM -> R.color.risk_medium + RiskLevel.HIGH -> R.color.risk_high } } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/DevicesFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/DevicesFragment.kt index 19d947be..104825df 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/DevicesFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/DevicesFragment.kt @@ -1,8 +1,7 @@ package de.seemoo.at_tracking_detection.ui.devices +import android.Manifest import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.text.InputFilter import android.transition.TransitionInflater @@ -14,15 +13,26 @@ import android.widget.EditText import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnPreDraw +import androidx.core.view.marginBottom +import androidx.core.view.updatePadding import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import androidx.navigation.NavDirections +import androidx.navigation.fragment.FragmentNavigatorExtras +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.card.MaterialCardView import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.database.models.device.BaseDevice @@ -30,57 +40,68 @@ import de.seemoo.at_tracking_detection.database.models.device.DeviceManager import de.seemoo.at_tracking_detection.database.models.device.DeviceType import de.seemoo.at_tracking_detection.databinding.FragmentDevicesBinding import de.seemoo.at_tracking_detection.ui.devices.filter.FilterDialogFragment +import de.seemoo.at_tracking_detection.ui.devices.filter.models.DateRangeFilter import de.seemoo.at_tracking_detection.ui.devices.filter.models.DeviceTypeFilter import de.seemoo.at_tracking_detection.ui.devices.filter.models.IgnoredFilter +import de.seemoo.at_tracking_detection.ui.devices.filter.models.LocationFilter import de.seemoo.at_tracking_detection.ui.devices.filter.models.NotifiedFilter -import de.seemoo.at_tracking_detection.ui.devices.filter.models.DateRangeFilter import de.seemoo.at_tracking_detection.ui.tracking.TrackingFragment +import de.seemoo.at_tracking_detection.util.Utility import de.seemoo.at_tracking_detection.util.risk.RiskLevelEvaluator import timber.log.Timber import java.time.LocalDate - @AndroidEntryPoint -abstract class DevicesFragment( - var showDevicesFound: Boolean = true, - var showAllDevices: Boolean = false, - var deviceType: DeviceType?=null, - var deviceType2: DeviceType?=null, -) : Fragment() { +class DevicesFragment : Fragment() { + + private val safeArgs: DevicesFragmentArgs by navArgs() - private val devicesViewModel: DevicesViewModel by viewModels() + private var showDevicesFound: Boolean = true + private var showAllDevices: Boolean = false + private var deviceType: DeviceType? = null + private var deviceType2: DeviceType? = null + private var preselectNotifiedFilter: String = "UNSELECTED" + private var preselectIgnoredFilter: String = "UNSELECTED" + private var preselectRemoveDateRange: Boolean = false + private var isShowingIgnoredDevices: Boolean = false + + val devicesViewModel: DevicesViewModel by viewModels() private val dialogFragment = FilterDialogFragment() private lateinit var deviceAdapter: DeviceAdapter - private var swipeDirs: Int = ItemTouchHelper.LEFT + private var swipeDirs: Int = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + // Initialize properties from SafeArgs + this.showDevicesFound = safeArgs.showDevicesFound + this.showAllDevices = safeArgs.showAllDevices + this.deviceType = safeArgs.deviceType + this.deviceType2 = safeArgs.deviceType2 + this.preselectNotifiedFilter = safeArgs.preselectNotifiedFilter + this.preselectIgnoredFilter = safeArgs.preselectIgnoredFilter + this.preselectRemoveDateRange = safeArgs.preselectRemoveDateRange - // Set up the view model here since this is only called when the Fragment gets created - // `onCreateView` is called every time the fragment is shown. Therefore it would not - // be good to handle the view model here. The View model should keep some state + super.onCreate(savedInstanceState) val emptyListText: Int var deviceInfoText = R.string.info_text_all_devices - if (!showDevicesFound) { + isShowingIgnoredDevices = true activity?.setTitle(R.string.title_ignored_devices) - emptyListText = R.string.ignored_device_list_empty - swipeDirs = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT - devicesViewModel.addOrRemoveFilter(IgnoredFilter.build()) + emptyListText = R.string.empty_list_others + devicesViewModel.addOrRemoveFilter(IgnoredFilter()) devicesViewModel.addOrRemoveFilter( - DeviceTypeFilter.build( + DeviceTypeFilter( DeviceManager.devices.map { it.deviceType }.toSet() ) ) } else { val relevantTrackingStartDate = RiskLevelEvaluator.relevantTrackingDateForRiskCalculation.toLocalDate() devicesViewModel.addOrRemoveFilter( - DateRangeFilter.build( + DateRangeFilter( relevantTrackingStartDate, LocalDate.now() ) @@ -91,7 +112,7 @@ abstract class DevicesFragment( emptyListText = R.string.empty_list_devices } else { // Only show tracker devices, for which a notification has been received - devicesViewModel.addOrRemoveFilter(NotifiedFilter.build()) + devicesViewModel.addOrRemoveFilter(NotifiedFilter()) deviceInfoText = R.string.info_text_only_trackers emptyListText = R.string.empty_list_trackers } @@ -99,12 +120,21 @@ abstract class DevicesFragment( if (deviceType != null && deviceType != DeviceType.UNKNOWN) { if (deviceType2 != null && deviceType2 != DeviceType.UNKNOWN) { - devicesViewModel.addOrRemoveFilter(DeviceTypeFilter.build(setOf(deviceType!!, deviceType2!!))) + devicesViewModel.addOrRemoveFilter(DeviceTypeFilter(setOf(deviceType!!, deviceType2!!))) } else { - devicesViewModel.addOrRemoveFilter(DeviceTypeFilter.build(setOf(deviceType!!))) + devicesViewModel.addOrRemoveFilter(DeviceTypeFilter(setOf(deviceType!!))) } } + // Apply location filter if locationId is provided + val locationId = safeArgs.locationId + if (locationId > 0) { + devicesViewModel.addOrRemoveFilter(LocationFilter(locationId)) + } + + // Apply preselected filter states if provided + applyPreselectFilters() + devicesViewModel.emptyListText.value = getString(emptyListText) devicesViewModel.infoText.value = getString(deviceInfoText) @@ -134,8 +164,37 @@ abstract class DevicesFragment( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() - view.findViewById(R.id.devices_recycler_view) - .doOnPreDraw { startPostponedEnterTransition() } + val recyclerView = view.findViewById(R.id.devices_recycler_view) + val filterContainer = view.findViewById(R.id.filter_fragment) + val emptyListInclude = view.findViewById(R.id.include_list_empty_explanation) + + recyclerView.doOnPreDraw { startPostponedEnterTransition() } + + ViewCompat.setOnApplyWindowInsetsListener(filterContainer) { v, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + v.updatePadding(top = bars.top) + + val navView = requireActivity().findViewById(R.id.main_nav_view) + val navHeight = if (navView != null && navView.height > 0) + navView.height + navView.marginBottom + else + bars.bottom + (88 * resources.displayMetrics.density).toInt() + + recyclerView.updatePadding(bottom = navHeight) + + // Apply bottom padding to the empty list view + emptyListInclude.updatePadding(bottom = navHeight) + + v.post { + val filterHeight = v.height + v.marginBottom + recyclerView.updatePadding(top = filterHeight) + emptyListInclude.updatePadding(top = filterHeight) + } + + // Stop system from adding unnecessary padding to te dialog filter + WindowInsetsCompat.CONSUMED + } devicesViewModel.devices.observe(viewLifecycleOwner) { deviceAdapter.submitList(it) @@ -144,10 +203,18 @@ abstract class DevicesFragment( val showAllButton = view.findViewById