diff --git a/api.properties b/api.properties index cf3f4b34..bb91fe29 100644 --- a/api.properties +++ b/api.properties @@ -1,2 +1,2 @@ -API_KEY="0pGhFp5V.3Me6ZeM4hqFnu8FDMrqZwpaau1fdE98Y" +API_KEY="3acPl2TP.lAyeGkWWlqPrgfWI9WbzqKKHejOmahJ3" 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 9a5abed0..9f804f0a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,8 +28,8 @@ android { applicationId "de.seemoo.at_tracking_detection" minSdkVersion 28 targetSdk = 36 - versionCode 54 - versionName "2.6.0" + versionCode 55 + versionName "2.6.1" buildConfigField "String", "API_KEY", apiProperties["API_KEY"] buildConfigField "String", "API_BASE_ADDRESS", apiProperties["API_BASE_ADDRESS"] @@ -92,10 +92,10 @@ dependencies { 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.3' - implementation 'androidx.navigation:navigation-ui-ktx:2.9.3' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.9.3' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.9.4' + implementation 'androidx.navigation:navigation-ui-ktx:2.9.4' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.9.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.cardview:cardview:1.0.0' @@ -103,12 +103,12 @@ dependencies { implementation "androidx.profileinstaller:profileinstaller:1.4.1" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' - implementation "androidx.activity:activity-ktx:1.10.1" + implementation "androidx.activity:activity-ktx:1.11.0" implementation 'com.squareup.retrofit2:retrofit:3.0.0' implementation 'com.squareup.retrofit2:converter-gson:3.0.0' implementation 'com.squareup.okhttp3:okhttp:5.1.0' implementation 'com.squareup.okhttp3:logging-interceptor:5.1.0' - implementation 'com.google.code.gson:gson:2.13.1' + implementation 'com.google.code.gson:gson:2.13.2' implementation "androidx.work:work-testing:$work_version" implementation 'androidx.core:core-ktx:1.17.0' debugImplementation 'com.squareup.okhttp3:logging-interceptor:5.1.0' @@ -128,7 +128,7 @@ dependencies { implementation 'com.github.mukeshsolanki:MarkdownView-Android:2.0.0' - implementation 'com.github.bumptech.glide:glide:5.0.4' + implementation 'com.github.bumptech.glide:glide:5.0.5' ksp "com.google.dagger:hilt-compiler:$hilt_compiler_version" ksp "androidx.hilt:hilt-compiler:$hilt_version" @@ -157,20 +157,25 @@ dependencies { //Compose // Integration with activities - implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.activity:activity-compose:1.11.0' // Compose Material3 Design implementation 'androidx.compose.material3:material3:1.3.2' implementation 'androidx.compose.material3:material3-window-size-class:1.3.2' // Animations - implementation 'androidx.compose.animation:animation:1.9.0' + implementation 'androidx.compose.animation:animation:1.9.1' // Tooling support (Previews, etc.) - implementation 'androidx.compose.ui:ui-tooling:1.9.0' + implementation 'androidx.compose.ui:ui-tooling:1.9.1' // Integration with ViewModels - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.3' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4' // UI Tests - androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.9.0' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.9.1' // When using a MDC theme implementation "com.google.android.material:compose-theme-adapter:1.2.1" + + // Google Review Handling + implementation("com.google.android.play:review:2.0.2") + implementation("com.google.android.play:review-ktx:2.0.2") + implementation("com.google.android.gms:play-services-base:18.8.0") } ksp { 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 2b8566a3..50f53943 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 @@ -170,7 +170,23 @@ class ATTrackingDetectionApplication : Application(), Configuration.Provider { requiredPermissions.add(Manifest.permission.BLUETOOTH_CONNECT) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requiredPermissions.add(Manifest.permission.POST_NOTIFICATIONS) + // requiredPermissions.add(Manifest.permission.POST_NOTIFICATIONS) + SharedPrefs.showMissingNotificationPermissionWarning = + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val backgroundLocationPermission = + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == PackageManager.PERMISSION_GRANTED + SharedPrefs.showMissingBackgroundLocationPermissionWarning = !backgroundLocationPermission + if (backgroundLocationPermission) { + SharedPrefs.useLocationInTrackingDetection = true + } } for (permission in requiredPermissions) { @@ -217,7 +233,7 @@ class ATTrackingDetectionApplication : Application(), Configuration.Provider { fun getCurrentApp(): ATTrackingDetectionApplication { return instance } - //TODO: Add real survey URL + 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/daos/NotificationDao.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/daos/NotificationDao.kt index 168d8744..28c81022 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 @@ -62,6 +62,9 @@ interface NotificationDao { @Query("SELECT COUNT(*) FROM notification WHERE deviceAddress == :deviceAddress AND createdAt >= :since AND falseAlarm = 1") fun getFalseAlarmForDeviceSinceCount(deviceAddress: String, since: LocalDateTime): Int + @Query("SELECT COUNT(*) FROM notification WHERE deviceAddress == :deviceAddress AND falseAlarm = 1") + fun getFalseAlarmForDeviceCount(deviceAddress: String): Int + @Transaction @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM notification") 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 0aeee895..ef7f6a80 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 @@ -50,6 +50,8 @@ class NotificationRepository @Inject constructor( fun getFalseAlarmForDeviceSinceCount(deviceAddress: String, since: LocalDateTime): Int = notificationDao.getFalseAlarmForDeviceSinceCount(deviceAddress, since) + fun getFalseAlarmForDeviceCount(deviceAddress: String): Int = notificationDao.getFalseAlarmForDeviceCount(deviceAddress) + fun existsNotificationForDevice(deviceAddress: String): Boolean = notificationDao.existsNotificationForDevice(deviceAddress) fun getAllNotifications(): List = notificationDao.getAllNotifications() diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationActionReceiver.kt b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationActionReceiver.kt index 3e504878..7ff50449 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationActionReceiver.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationActionReceiver.kt @@ -38,6 +38,15 @@ class NotificationActionReceiver : BroadcastReceiver() { Timber.e("Notification id missing!") return } + val notificationTag = intent.getStringExtra("notificationTag") + + fun cancelNotification() { + if (notificationTag.isNullOrEmpty()) { + notificationManagerCompat.cancel(notificationId) + } else { + notificationManagerCompat.cancel(notificationTag, notificationId) + } + } when (intent.action) { NotificationConstants.FALSE_ALARM_ACTION -> { @@ -46,7 +55,8 @@ class NotificationActionReceiver : BroadcastReceiver() { notificationRepository.setFalseAlarm(notificationId, true) } // Dismiss the notification immediately for this action. - notificationManagerCompat.cancel(notificationId) + cancelNotification() + backgroundWorkScheduler.scheduleFalseAlarm(notificationId, notificationTag) } NotificationConstants.IGNORE_DEVICE_ACTION -> { val deviceAddress = intent.getStringExtra("deviceAddress") @@ -59,13 +69,14 @@ class NotificationActionReceiver : BroadcastReceiver() { deviceRepository.setIgnoreFlag(deviceAddress, true) } // Dismiss the notification immediately for this action. - notificationManagerCompat.cancel(notificationId) + cancelNotification() + backgroundWorkScheduler.scheduleIgnoreDevice(deviceAddress, notificationId, notificationTag) } NotificationConstants.CLICKED_ACTION -> { GlobalScope.launch(Dispatchers.IO) { notificationRepository.setClicked(notificationId, true) } - notificationManagerCompat.cancel(notificationId) + cancelNotification() } NotificationConstants.DISMISSED_ACTION -> { GlobalScope.launch(Dispatchers.IO) { @@ -75,4 +86,3 @@ class NotificationActionReceiver : BroadcastReceiver() { } } } - diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationBuilder.kt b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationBuilder.kt index ca3f21e5..0a168d04 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationBuilder.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationBuilder.kt @@ -9,6 +9,7 @@ import android.os.Build import android.os.Bundle import android.os.SystemClock import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.R @@ -29,7 +30,7 @@ import javax.inject.Singleton @Singleton class NotificationBuilder @Inject constructor( - @ApplicationContext private val context: Context + @param:ApplicationContext private val context: Context ) { private fun pendingNotificationIntent(bundle: Bundle, notificationId: Int): PendingIntent { @@ -83,11 +84,14 @@ class NotificationBuilder @Inject constructor( private fun buildPendingIntent( bundle: Bundle, notificationAction: String, + notificationId: Int, code: Int ): PendingIntent { val intent = Intent(context, NotificationActionReceiver::class.java).apply { action = notificationAction putExtras(bundle) + val tag = bundle.getString("notificationTag") ?: "none" + data = "airguard://notif/$tag/$notificationId/$notificationAction".toUri() } val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -96,9 +100,11 @@ class NotificationBuilder @Inject constructor( PendingIntent.FLAG_UPDATE_CURRENT } + val uniqueRequestCode = (notificationId and 0x7FFFFFFF) xor (code shl 16) + return PendingIntent.getBroadcast( context, - code, + uniqueRequestCode, intent, flags ) @@ -118,7 +124,10 @@ class NotificationBuilder @Inject constructor( } ?: "UNKNOWN" - val bundle: Bundle = packBundle(deviceAddress, deviceTypeString, notificationId) + val bundle: Bundle = packBundle(deviceAddress, deviceTypeString, notificationId).apply { + // Provide the tag so the receiver can cancel using tag+id + putString("notificationTag", NotificationService.TRACKING_NOTIFICATION_TAG) + } val device = baseDevice.device val notificationText: String val notificationTitle: String @@ -165,6 +174,7 @@ class NotificationBuilder @Inject constructor( buildPendingIntent( bundle, NotificationConstants.FALSE_ALARM_ACTION, + notificationId, NotificationConstants.FALSE_ALARM_CODE ) ).setAutoCancel(true) @@ -176,6 +186,7 @@ class NotificationBuilder @Inject constructor( buildPendingIntent( bundle, NotificationConstants.IGNORE_DEVICE_ACTION, + notificationId, NotificationConstants.IGNORE_DEVICE_CODE ) ).setAutoCancel(true) @@ -185,6 +196,7 @@ class NotificationBuilder @Inject constructor( buildPendingIntent( bundle, NotificationConstants.DISMISSED_ACTION, + notificationId, NotificationConstants.DISMISSED_CODE ) ).setAutoCancel(true) @@ -200,7 +212,9 @@ class NotificationBuilder @Inject constructor( observationPositive: Boolean ): Notification { Timber.d("Notification with id $notificationId for device $deviceAddress has been build!") - val bundle: Bundle = packBundle(deviceAddress, deviceTypeString, notificationId) + val bundle: Bundle = packBundle(deviceAddress, deviceTypeString, notificationId).apply { + putString("notificationTag", NotificationService.OBSERVE_TRACKER_NOTIFICATION_TAG) + } val notifyText = if (observationPositive) { context.resources.getQuantityString( @@ -227,6 +241,7 @@ class NotificationBuilder @Inject constructor( buildPendingIntent( bundle, NotificationConstants.DISMISSED_ACTION, + notificationId, NotificationConstants.DISMISSED_CODE ) ).setAutoCancel(true) @@ -236,7 +251,10 @@ class NotificationBuilder @Inject constructor( } fun buildObserveTrackerFailedNotification(notificationId: Int): Notification { - val bundle: Bundle = Bundle().apply { putInt("notificationId", notificationId) } + val bundle: Bundle = Bundle().apply { + putInt("notificationId", notificationId) + putString("notificationTag", NotificationService.OBSERVE_TRACKER_NOTIFICATION_TAG) + } return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID) .setContentTitle(context.getString(R.string.notification_observe_tracker_title_base)) @@ -251,7 +269,10 @@ class NotificationBuilder @Inject constructor( fun buildBluetoothErrorNotification(): Notification { val notificationId = -100 - val bundle: Bundle = Bundle().apply { putInt("notificationId", notificationId) } + val bundle: Bundle = Bundle().apply { + putInt("notificationId", notificationId) + putString("notificationTag", NotificationService.BLE_SCAN_ERROR_TAG) + } return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID) .setContentTitle(context.getString(R.string.notification_title_ble_error)) diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationService.kt b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationService.kt index 6ffc5a31..ee5883e9 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationService.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/NotificationService.kt @@ -29,8 +29,7 @@ class NotificationService @Inject constructor( ) { @SuppressLint("MissingPermission") suspend fun sendTrackingNotification(baseDevice: BaseDevice) { - val notificationId = generateUniqueNotificationId() - notificationViewModel.insertToDb(deviceAddress = baseDevice.address) + val notificationId = notificationViewModel.insertToDb(deviceAddress = baseDevice.address) with(notificationManagerCompat) { if (this.areNotificationsEnabled() && !SharedPrefs.deactivateBackgroundScanning) { notify( @@ -175,7 +174,7 @@ class NotificationService @Inject constructor( "de.seemoo.at_tracking_detection.ble_scan_error_notification" const val OBSERVE_TRACKER_NOTIFICATION_TAG = "de.seemoo.at_tracking_detection.observe_tracker_notification" - // const val SURVEY_INFO_TAG = "de.seemoo.at_tracking_detection.survey_info" + const val SURVEY_INFO_TAG = "de.seemoo.at_tracking_detection.survey_info" fun generateUniqueNotificationId(): Int { return Random.nextInt() diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/FalseAlarmWorker.kt b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/FalseAlarmWorker.kt index b0ab1d23..6f1e7f62 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/FalseAlarmWorker.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/FalseAlarmWorker.kt @@ -17,17 +17,23 @@ class FalseAlarmWorker @AssistedInject constructor( private val notificationViewModel: NotificationViewModel, private val notificationManagerCompat: NotificationManagerCompat ) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { val notificationId = inputData.getInt("notificationId", -1) if (notificationId == -1) { Timber.e("No notification id passed!") return Result.failure() } + val notificationTag = inputData.getString("notificationTag") + notificationViewModel.setFalseAlarm(notificationId, true) - //TODO: cancel specific notification by calling cancel(notificationId) which somehow doesn't work... - notificationManagerCompat.cancelAll() - Timber.d("Marked notification $notificationId as false alarm!") + + if (notificationTag.isNullOrEmpty()) { + notificationManagerCompat.cancel(notificationId) + } else { + notificationManagerCompat.cancel(notificationTag, notificationId) + } + + Timber.d("Marked notification $notificationId as false alarm and canceled it!") return Result.success() } } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/IgnoreDeviceWorker.kt b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/IgnoreDeviceWorker.kt index 7e241581..9b0066cb 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/IgnoreDeviceWorker.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/notifications/worker/IgnoreDeviceWorker.kt @@ -29,9 +29,16 @@ class IgnoreDeviceWorker @AssistedInject constructor( Timber.e("No notification id passed!") return Result.failure() } + val notificationTag = inputData.getString("notificationTag") + deviceViewModel.setIgnoreFlag(deviceAddress, true) - //TODO: cancel specific notification by calling cancel(notificationId) which somehow doesn't work... - notificationManagerCompat.cancelAll() + + if (notificationTag.isNullOrEmpty()) { + notificationManagerCompat.cancel(notificationId) + } else { + notificationManagerCompat.cancel(notificationTag, notificationId) + } + Timber.d("Added device $deviceAddress to the ignored devices list!") return Result.success() } 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 490fd782..c7db1639 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,9 +4,11 @@ import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.os.StrictMode -import androidx.activity.enableEdgeToEdge +import android.view.ViewGroup 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.navigation.findNavController @@ -51,9 +53,9 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - enableEdgeToEdge() + + configureSystemBars(this, edgeToEdge = true, applyRootPadding = false) sharedPreferences.registerOnSharedPreferenceChangeListener(this) - configureSystemBars() val configuration = Configuration.getInstance() configuration.load(this, PreferenceManager.getDefaultSharedPreferences(this)) @@ -111,25 +113,6 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh } } - private fun configureSystemBars() { - val isDarkTheme = Utility.isActualThemeDark(context = this) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - - if (isDarkTheme) { - windowInsetsController.isAppearanceLightStatusBars = false - windowInsetsController.isAppearanceLightNavigationBars = false - } else { - windowInsetsController.isAppearanceLightStatusBars = true - windowInsetsController.isAppearanceLightNavigationBars = true - } - } else { - WindowCompat.setDecorFitsSystemWindows(window, false) - } - } - override fun onResume() { super.onResume() Timber.d("MainActivity onResume called") @@ -186,6 +169,39 @@ class MainActivity : AppCompatActivity(), SharedPreferences.OnSharedPreferenceCh 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?) { 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 02c11946..56da9818 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 @@ -140,7 +140,7 @@ class OnboardingActivity : AppIntro() { askForPermissions( permissions = arrayOf(Manifest.permission.BLUETOOTH_SCAN), slideNumber = slideNumber, - required = false + required = true ) return true } @@ -234,6 +234,9 @@ class OnboardingActivity : AppIntro() { private fun handleRequiredPermission(permissionName: String) { if (permissionName == Manifest.permission.ACCESS_BACKGROUND_LOCATION) { SharedPrefs.useLocationInTrackingDetection = false + goToNextSlide() + } else if (permissionName == Manifest.permission.POST_NOTIFICATIONS) { + goToNextSlide() } else if (dialog?.isShowing != true) { MaterialAlertDialogBuilder(this) .setTitle(R.string.permission_required) 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 6689304d..232f0123 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 @@ -4,46 +4,55 @@ import android.content.Intent import android.os.Bundle import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class TrackingNotificationActivity : AppCompatActivity() { + @Inject + lateinit var notificationRepository: NotificationRepository + private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_tracking) + + // For notification entry: disable edge-to-edge so content area automatically respects system bars + MainActivity.configureSystemBars(this, edgeToEdge = true, applyRootPadding = true) + val navHostFragment = supportFragmentManager.findFragmentById(R.id.tracking_host_fragment) as NavHostFragment navController = navHostFragment.navController - navigateToTrackingFragment() + // Only navigate on fresh creation or when a new intent with different device arrives + if (savedInstanceState == null) { + navigateToTrackingFragment(firstTime = true) + } else { + updateActionBarTitle() + } onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - onSupportNavigateUp() + handleBackNavigation() } }) } - override fun onSupportNavigateUp(): Boolean { - return if (navController.currentDestination?.id == R.id.trackingFragment) { - finish() - true - } else { - navController.navigateUp() - } - } - 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 @@ -67,35 +76,68 @@ class TrackingNotificationActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - - navigateToTrackingFragment() + navigateToTrackingFragment(replaceGraph = true, firstTime = true) } - private fun navigateToTrackingFragment() { + private fun navigateToTrackingFragment(replaceGraph: Boolean = false, firstTime: Boolean = false) { val deviceAddress = intent.getStringExtra("deviceAddress") val deviceTypeAsString = intent.getStringExtra("deviceTypeAsString") ?: "UNKNOWN" val notificationId = intent.getIntExtra("notificationId", -1) Timber.d("Tracking Activity with device $deviceAddress and notification $notificationId started!") + if (notificationId != -1) { + lifecycleScope.launch(Dispatchers.IO) { + notificationRepository.setClicked(notificationId, true) + } + } + if (deviceAddress == null) { - Timber.e("Device address is needed! Going home...") - this.onSupportNavigateUp() - } else { - // Workaround: Somehow not possible to use getString with deviceAddress as an Argument - var getTitle = getString(R.string.title_devices_tracking) - getTitle = getTitle.replace("{deviceAddress}", deviceAddress.toString()) - supportActionBar?.title = getTitle - - val args = TrackingFragmentArgs( - deviceAddress = deviceAddress, - deviceTypeAsString = deviceTypeAsString, - notificationId = notificationId - ).toBundle() + Timber.e("Device was not provided! Finishing TrackingNotificationActivity.") + finish() + return + } + + updateActionBarTitle(deviceAddress) + val args = TrackingFragmentArgs(deviceAddress, deviceTypeAsString, notificationId).toBundle() + + if (replaceGraph || (firstTime && navController.currentDestination == null)) { navController.setGraph(R.navigation.main_navigation) + // When accessing through Notification: trackingFragment is the root + val startDest = navController.graph.startDestinationId val navOptions = NavOptions.Builder() - // .setPopUpTo(R.id.navigation_dashboard, true) + .setPopUpTo(startDest, inclusive = true) + .setLaunchSingleTop(true) .build() navController.navigate(R.id.trackingFragment, args, navOptions) + return + } + + if (navController.currentDestination?.id != R.id.trackingFragment) { + navController.navigate( + R.id.trackingFragment, + args, + NavOptions.Builder().setLaunchSingleTop(true).build() + ) } } + + private fun updateActionBarTitle(deviceAddress: String? = intent.getStringExtra("deviceAddress")) { + deviceAddress ?: return + var getTitle = getString(R.string.title_devices_tracking) + getTitle = getTitle.replace("{deviceAddress}", deviceAddress) + supportActionBar?.title = getTitle + } + + private fun handleBackNavigation() { + if (navController.currentDestination?.id != R.id.trackingFragment && navController.popBackStack()) { + return + } + // When at root --> finish + finish() + } + + override fun onSupportNavigateUp(): Boolean { + handleBackNavigation() + return true + } } \ No newline at end of file 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 656d3623..740d9caf 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 @@ -1,7 +1,6 @@ package de.seemoo.at_tracking_detection.ui.dashboard import android.annotation.SuppressLint -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -20,6 +19,7 @@ import androidx.navigation.fragment.findNavController import com.bumptech.glide.Glide import com.google.android.material.card.MaterialCardView import dagger.hilt.android.AndroidEntryPoint +import de.seemoo.at_tracking_detection.BuildConfig import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.databinding.FragmentDashboardRiskBinding import de.seemoo.at_tracking_detection.util.SharedPrefs @@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint @@ -37,6 +38,9 @@ class DashboardRiskFragment : Fragment() { private var _binding: FragmentDashboardRiskBinding? = null private val binding get() = _binding!! + @Inject + lateinit var reviewController: ReviewController + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -58,6 +62,9 @@ class DashboardRiskFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // Increment app open count + reviewController.incrementAppOpenCount() + val riskCard: MaterialCardView = view.findViewById(R.id.risk_card) riskCard.setOnClickListener { val directions: NavDirections = @@ -104,6 +111,32 @@ class DashboardRiskFragment : Fragment() { articles = listOf(bugArticle) + articles } + if (SharedPrefs.showMissingNotificationPermissionWarning) { + val notificationArticle = Article( + title = getString(R.string.notification_permission_missing_title), + author = "System", + readingTime = 0, + previewText = getString(R.string.notification_permission_missing_text), + cardColor = "warning_light_red", + preview_image = "", + filename = "" + ) + articles = listOf(notificationArticle) + articles + } + + if (SharedPrefs.showMissingBackgroundLocationPermissionWarning) { + val locationArticle = Article( + title = getString(R.string.background_location_permission_missing_title), + author = "System", + readingTime = 0, + previewText = getString(R.string.background_location_permission_missing_text), + cardColor = "warning_light_red", + preview_image = "", + filename = "" + ) + articles = listOf(locationArticle) + articles + } + // Create a new LinearLayout to hold the ArticleCards val articleCardsLinearLayout = LinearLayout(context) articleCardsLinearLayout.orientation = LinearLayout.VERTICAL @@ -170,6 +203,19 @@ class DashboardRiskFragment : Fragment() { progressBar.visibility = View.GONE } } + + // Check if we should show review after data is loaded + checkAndShowReview() + } + + 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") + } } override fun onStart() { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ReviewController.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ReviewController.kt new file mode 100644 index 00000000..b37c9c0e --- /dev/null +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ReviewController.kt @@ -0,0 +1,159 @@ +package de.seemoo.at_tracking_detection.ui.dashboard + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.play.core.review.ReviewInfo +import com.google.android.play.core.review.ReviewManager +import com.google.android.play.core.review.ReviewManagerFactory +import de.seemoo.at_tracking_detection.util.SharedPrefs +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReviewController @Inject constructor( + private val context: Context +) { + private var reviewManager: ReviewManager? = null + private var reviewInfo: ReviewInfo? = null + private var isReviewFlowReady = false + private var pendingReviewRequest: (() -> Unit)? = null + + companion object { + private const val REVIEW_THRESHOLD = 20 + } + + init { + if (isGooglePlayServicesAvailable()) { + reviewManager = ReviewManagerFactory.create(context) + prepareReviewFlow() + } + } + + private fun isGooglePlayServicesAvailable(): Boolean { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val connectionResult = googleApiAvailability.isGooglePlayServicesAvailable(context) + return connectionResult == ConnectionResult.SUCCESS + } + + fun incrementAppOpenCount() { + val currentCount: Int = SharedPrefs.appOpenCount + SharedPrefs.appOpenCount = currentCount + 1 + Timber.d("App opened ${currentCount + 1} times") + } + + private fun prepareReviewFlow() { + reviewManager?.let { manager -> + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + reviewInfo = task.result + isReviewFlowReady = true + Timber.d("Review flow prepared successfully") + + // Execute pending review request if any + pendingReviewRequest?.invoke() + pendingReviewRequest = null + } else { + Timber.w("Failed to prepare review flow: ${task.exception}") + isReviewFlowReady = false + } + } + } + } + + fun shouldShowReview(): Boolean { + if (!isGooglePlayServicesAvailable()) { + Timber.d("Google Play Services not available") + return false + } + + val appOpenCount: Int = SharedPrefs.appOpenCount + val reviewShown: Boolean = SharedPrefs.reviewShown + + Timber.d("Review check - appOpenCount: $appOpenCount, reviewShown: $reviewShown, reviewFlowReady: $isReviewFlowReady, reviewInfo: ${reviewInfo != null}") + + return appOpenCount >= REVIEW_THRESHOLD && !reviewShown && isReviewFlowReady && reviewInfo != null + } + + fun requestReviewDialog(activity: FragmentActivity, onComplete: () -> Unit = {}) { + if (!isGooglePlayServicesAvailable()) { + Timber.d("Google Play Services not available, skipping review") + onComplete() + return + } + + val appOpenCount: Int = SharedPrefs.appOpenCount + val reviewShown: Boolean = SharedPrefs.reviewShown + + if (appOpenCount < REVIEW_THRESHOLD || reviewShown) { + Timber.d("Review conditions not met - count: $appOpenCount, shown: $reviewShown") + onComplete() + return + } + + if (isReviewFlowReady && reviewInfo != null) { + showReviewDialog(activity, onComplete) + } else { + Timber.d("Review flow not ready yet, queuing request") + pendingReviewRequest = { + if (shouldShowReview()) { + showReviewDialog(activity, onComplete) + } else { + onComplete() + } + } + } + } + + private fun showReviewDialog(activity: FragmentActivity, onComplete: () -> Unit = {}) { + reviewManager?.let { manager -> + reviewInfo?.let { info -> + Timber.d("Launching review flow") + val flow = manager.launchReviewFlow(activity, info) + flow.addOnCompleteListener { task -> + if (task.isSuccessful) { + Timber.d("Review flow completed successfully") + markReviewAsShown() + } else { + Timber.w("Review flow failed: ${task.exception}") + } + onComplete() + } + } ?: run { + Timber.w("ReviewInfo is null, cannot show review") + onComplete() + } + } ?: run { + Timber.w("ReviewManager is null, cannot show review") + onComplete() + } + } + + private fun markReviewAsShown() { + SharedPrefs.reviewShown = true + Timber.d("Review marked as shown") + } + + fun getAppOpenCount(): Int { + return SharedPrefs.appOpenCount + } + + fun resetReviewStatus() { + SharedPrefs.reviewShown = false + Timber.d("Review status reset") + } + + fun debugReviewStatus() { + Timber.d("=== Review Debug Info ===") + Timber.d("Google Play Services available: ${isGooglePlayServicesAvailable()}") + Timber.d("App open count: ${SharedPrefs.appOpenCount}") + Timber.d("Review shown: ${SharedPrefs.reviewShown}") + Timber.d("Review flow ready: $isReviewFlowReady") + Timber.d("ReviewInfo available: ${reviewInfo != null}") + Timber.d("Should show review: ${shouldShowReview()}") + Timber.d("========================") + } +} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ReviewModule.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ReviewModule.kt new file mode 100644 index 00000000..88732e56 --- /dev/null +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ReviewModule.kt @@ -0,0 +1,20 @@ +package de.seemoo.at_tracking_detection.ui.dashboard + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReviewModule { + + @Provides + @Singleton + fun provideReviewController(@ApplicationContext context: Context): ReviewController { + return ReviewController(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingViewModel.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingViewModel.kt index 746bb274..3ac69fa5 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingViewModel.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/tracking/TrackingViewModel.kt @@ -89,6 +89,7 @@ class TrackingViewModel @Inject constructor( canBeIgnored.postValue(deviceType.value!!.canBeIgnored(ConnectionState.OVERMATURE_OFFLINE)) val notification = notificationRepository.notificationForDevice(device).firstOrNull() notification?.let { notificationId.postValue(it.notificationId) } + falseAlarm.postValue(notification?.falseAlarm ?: false) // Load last seen times viewModelScope.launch { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/util/SharedPrefs.kt b/app/src/main/java/de/seemoo/at_tracking_detection/util/SharedPrefs.kt index 5c64652f..9aa7821b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/util/SharedPrefs.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/util/SharedPrefs.kt @@ -197,21 +197,35 @@ object SharedPrefs { var dismissSurveyInformation: Boolean get() { return sharedPreferences.getBoolean("dismiss_survey_information", false) - }set(value) { + } set(value) { sharedPreferences.edit { putBoolean("dismiss_survey_information", value) } } var showSamsungAndroid15BugNotification: Boolean get() { return sharedPreferences.getBoolean("samsung_bug_notification", false) - }set(value) { + } set(value) { sharedPreferences.edit { putBoolean("samsung_bug_notification", value) } } + var showMissingNotificationPermissionWarning: Boolean + get() { + return sharedPreferences.getBoolean("missing_notification_permission_warning", false) + } set(value) { + sharedPreferences.edit { putBoolean("missing_notification_permission_warning", value) } + } + + var showMissingBackgroundLocationPermissionWarning: Boolean + get() { + return sharedPreferences.getBoolean("missing_background_permission_warning", false) + } set(value) { + sharedPreferences.edit { putBoolean("missing_background_permission_warning", value) } + } + var showGenericBluetoothBugNotification: Boolean get() { return sharedPreferences.getBoolean("generic_bluetooth_bug_notification", false) - }set(value) { + } set(value) { sharedPreferences.edit { putBoolean("generic_bluetooth_bug_notification", value) } } @@ -226,8 +240,7 @@ object SharedPrefs { } } return null - } - set(value) { + } set(value) { sharedPreferences.edit { putString( "survey_notification_date", @@ -254,6 +267,24 @@ object SharedPrefs { sharedPreferences.edit { putString("risk_sensitivity", value) } } + var appOpenCount: Int + // How often the app has been opened + get() { + return sharedPreferences.getInt("app_open_count", 0) + } + set(value) { + sharedPreferences.edit { putInt("app_open_count", value) } + } + + var reviewShown: Boolean + // If the review dialog has been shown + get() { + return sharedPreferences.getBoolean("review_shown", false) + } + set(value) { + sharedPreferences.edit { putBoolean("review_shown", value) } + } + var devicesFilter: Set get() { val allOptions = getAllDevicesFilterOptions() diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/util/Utility.kt b/app/src/main/java/de/seemoo/at_tracking_detection/util/Utility.kt index 5b71a313..bf760e2b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/util/Utility.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/util/Utility.kt @@ -19,6 +19,7 @@ import android.os.Build import android.os.Bundle import android.util.Log import android.view.View +import android.view.ViewTreeObserver import androidx.appcompat.app.AppCompatDelegate import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat.requestPermissions @@ -153,6 +154,9 @@ object Utility { ) + // Remove previously added clusterer overlays to avoid performance issues + map.overlays.removeAll { it is RadiusMarkerClusterer } + val clusterer = RadiusMarkerClusterer(context) val clusterIcon = BonusPackHelper.getBitmapFromVectorDrawable(context, icon) clusterer.setIcon(clusterIcon) @@ -186,7 +190,7 @@ object Utility { if (geoPointList.isEmpty()) { mapController.setZoom(MAX_ZOOM_LEVEL) - map.post { map.invalidate() } + runWhenMapReady(map) { map.invalidate() } return false } @@ -195,20 +199,39 @@ object Utility { myLocationOverlay?.disableFollowLocation() val boundingBox = BoundingBox.fromGeoPointsSafe(geoPointList) - map.post { + // Ensure the map has been loaded before attempting to zoom. + runWhenMapReady(map) { try { - Timber.d("Zoom in to bounds -> $boundingBox") + Timber.d("Zoom in to bounds -> $boundingBox (w=${map.width}, h=${map.height})") map.zoomToBoundingBox(boundingBox, true, 100, MAX_ZOOM_LEVEL, 1) } catch (e: IllegalArgumentException) { mapController.setCenter(boundingBox.centerWithDateLine) mapController.setZoom(10.0) Timber.e("Failed to zoom to bounding box! ${e.message}") + } finally { + map.invalidate() } } return true } + // Helper to run actions only after the MapView is loaded (has non-zero size) + private fun runWhenMapReady(map: MapView, action: () -> Unit) { + if (map.width > 0 && map.height > 0) { + map.post { action() } + return + } + map.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (map.width > 0 && map.height > 0) { + map.viewTreeObserver.removeOnGlobalLayoutListener(this) + map.post { action() } + } + } + }) + } + fun fetchLocationListFromBeaconList(locations: List): List { val uniqueLocations = locations .distinctBy { it.locationId } // Filter out duplicates based on locationId diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/util/risk/RiskLevelEvaluator.kt b/app/src/main/java/de/seemoo/at_tracking_detection/util/risk/RiskLevelEvaluator.kt index 8964f9a6..0493e619 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/util/risk/RiskLevelEvaluator.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/util/risk/RiskLevelEvaluator.kt @@ -98,12 +98,18 @@ class RiskLevelEvaluator( private const val NUMBER_OF_BEACONS_BEFORE_ALARM: Int = 3 // Number of total beacons before notification is created private const val MAX_ACCURACY_FOR_LOCATIONS: Float = 100.0F // Minimum Location accuracy for high risk const val MAX_NUMBER_MEDIUM_RISK: Long = 3 // Maximum number of devices with MEDIUM risk until the total risk level is set to high - val relevantTrackingDateForRiskCalculation: LocalDateTime = LocalDateTime.now().minusDays( - RELEVANT_DAYS_RISK_LEVEL) // Fallback Option, if possible use getRelevantTrackingDate() Function - val deleteBeforeDate: LocalDateTime = LocalDateTime.now().minusDays(DELETE_SAFE_DEVICES_OLDER_THAN_DAYS) - val deleteSafeGoogleTrackersBeforeDate: LocalDateTime = LocalDateTime.now().minusHours(DELETE_SAFE_GOOGLE_DEVICES_OLDER_THAN_HOURS) - val matchNotConnectableGoogleTrackersBeforeDate: LocalDateTime = LocalDateTime.now().minusHours(MATCH_NOT_CONNECTABLE_GOOGLE_DEVICES_OLDER_THAN_HOURS) - private val relevantNotificationDate: LocalDateTime = LocalDateTime.now().minusDays(RELEVANT_DAYS_NOTIFICATIONS) + + // Dates used for queries. They will calculate a new date when accessed again + val relevantTrackingDateForRiskCalculation: LocalDateTime + get() = LocalDateTime.now().minusDays(RELEVANT_DAYS_RISK_LEVEL) + val deleteBeforeDate: LocalDateTime + get() = LocalDateTime.now().minusDays(DELETE_SAFE_DEVICES_OLDER_THAN_DAYS) + val deleteSafeGoogleTrackersBeforeDate: LocalDateTime + get() = LocalDateTime.now().minusHours(DELETE_SAFE_GOOGLE_DEVICES_OLDER_THAN_HOURS) + val matchNotConnectableGoogleTrackersBeforeDate: LocalDateTime + get() = LocalDateTime.now().minusHours(MATCH_NOT_CONNECTABLE_GOOGLE_DEVICES_OLDER_THAN_HOURS) + private val relevantNotificationDate: LocalDateTime + get() = LocalDateTime.now().minusDays(RELEVANT_DAYS_NOTIFICATIONS) // Default Values: A single tracker gets tracked at least for x minutes until notification is created private const val MINUTES_AT_LEAST_TRACKED_BEFORE_ALARM_HIGH: Long = 30 diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkBuilder.kt b/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkBuilder.kt index df037d0d..98745a61 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkBuilder.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkBuilder.kt @@ -45,19 +45,23 @@ class BackgroundWorkBuilder @Inject constructor() { .setBackoffCriteria(BackoffPolicy.LINEAR, WorkerConstants.KIND_DELAY, TimeUnit.MINUTES) .build() - fun buildIgnoreDeviceWorker(deviceAddress: String, notificationId: Int): OneTimeWorkRequest = + fun buildIgnoreDeviceWorker(deviceAddress: String, notificationId: Int, notificationTag: String?): OneTimeWorkRequest = OneTimeWorkRequestBuilder().addTag(WorkerConstants.IGNORE_DEVICE_WORKER) .setBackoffCriteria(BackoffPolicy.LINEAR, WorkerConstants.KIND_DELAY, TimeUnit.MINUTES) .setInputData( Data.Builder().putString("deviceAddress", deviceAddress) - .putInt("notificationId", notificationId).build() + .putInt("notificationId", notificationId) + .putString("notificationTag", notificationTag) + .build() ) .build() - fun buildFalseAlarmWorker(notificationId: Int): OneTimeWorkRequest = + fun buildFalseAlarmWorker(notificationId: Int, notificationTag: String?): OneTimeWorkRequest = OneTimeWorkRequestBuilder().addTag(WorkerConstants.FALSE_ALARM_WORKER) .setBackoffCriteria(BackoffPolicy.LINEAR, WorkerConstants.KIND_DELAY, TimeUnit.MINUTES) - .setInputData(Data.Builder().putInt("notificationId", notificationId).build()) + .setInputData(Data.Builder().putInt("notificationId", notificationId) + .putString("notificationTag", notificationTag) + .build()) .build() private fun buildConstraints(): Constraints = diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkScheduler.kt b/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkScheduler.kt index 04a1f598..7bf2d5b2 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkScheduler.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/worker/BackgroundWorkScheduler.kt @@ -7,19 +7,19 @@ import android.content.Intent import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.map -import androidx.work.* +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.Operation +import androidx.work.WorkInfo +import androidx.work.WorkManager import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication import de.seemoo.at_tracking_detection.BuildConfig -import de.seemoo.at_tracking_detection.util.Utility +import de.seemoo.at_tracking_detection.util.SharedPrefs import timber.log.Timber import java.time.LocalDateTime import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton -import android.Manifest -import de.seemoo.at_tracking_detection.util.SharedPrefs -import java.time.Instant -import java.util.TimeZone @Singleton class BackgroundWorkScheduler @Inject constructor( @@ -78,17 +78,17 @@ class BackgroundWorkScheduler @Inject constructor( fun removeShareData() = workManager.cancelUniqueWork(WorkerConstants.PERIODIC_SEND_STATISTICS_WORKER) - fun scheduleIgnoreDevice(deviceAddress: String, notificationId: Int) = + fun scheduleIgnoreDevice(deviceAddress: String, notificationId: Int, notificationTag: String?) = workManager.enqueueUniqueWork( WorkerConstants.IGNORE_DEVICE_WORKER, ExistingWorkPolicy.APPEND_OR_REPLACE, - backgroundWorkBuilder.buildIgnoreDeviceWorker(deviceAddress, notificationId) + backgroundWorkBuilder.buildIgnoreDeviceWorker(deviceAddress, notificationId, notificationTag) ).also { it.logOperationSchedule(WorkerConstants.IGNORE_DEVICE_WORKER) } - fun scheduleFalseAlarm(notificationId: Int) = workManager.enqueueUniqueWork( - WorkerConstants.IGNORE_DEVICE_WORKER, + fun scheduleFalseAlarm(notificationId: Int, notificationTag: String?) = workManager.enqueueUniqueWork( + WorkerConstants.FALSE_ALARM_WORKER, ExistingWorkPolicy.APPEND_OR_REPLACE, - backgroundWorkBuilder.buildFalseAlarmWorker(notificationId) + backgroundWorkBuilder.buildFalseAlarmWorker(notificationId, notificationTag) ).also { it.logOperationSchedule(WorkerConstants.FALSE_ALARM_WORKER) } private fun Operation.logOperationSchedule(uniqueWorker: String) = diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bf01d8ea..9df18a39 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -439,4 +439,8 @@ Es gab Probleme beim Scannen von Bluetooth. Stelle sicher, dass AirGuard alle erforderlichen Berechtigungen hat und dass Bluetooth eingeschaltet ist. Falls dieser Fehler weiterhin auftritt, versuche, dein Smartphone neu zu starten. Kommentar zu diesem Tracker + Benachrichtigungen sind deaktiviert + Benachrichtigungen sind für diese App deaktiviert. Du wirst nicht gewarnt, wenn dir ein Tracker folgen sollte. Um dies zu ändern, aktiviere Benachrichtigungen für diese App in deinen Systemeinstellungen. + Hintergrund-Standortberechtigung deaktiviert + Die Berechtigung für den Standort im Hintergrund wurde dieser App nicht erteilt. Du wirst nicht gewarnt, wenn dir ein Tracker folgt, während die App geschlossen ist. Um dies zu ändern, aktiviere die Hintergrund-Standortberechtigung für diese App in deinen Systemeinstellungen. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ee0ed00b..1cfd1dd0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -405,4 +405,8 @@ このデバイスはAndroid 15以上で発生するBluetooth Low Energyスキャンの不具合の影響を受けています。すべてのアプリがBluetoothスキャンを利用できません。スマートフォンを再起動してください。 Bluetoothスキャン中に問題が発生しました。AirGuardに必要な権限があり、Bluetoothがオンになっていることを確認してください。問題が解決しない場合はスマートフォンを再起動してください。 コメントを追加 + 通知が無効になっています + このアプリでは通知が無効になっています。トラッカーがあなたを追跡している場合、警告は表示されません。通知を有効にするには、システム設定でこのアプリの通知を有効にしてください。 + バックグラウンドでの位置情報取得が無効です + このアプリにはバックグラウンドでの位置情報取得の許可がありません。アプリが閉じている間にトラッカーがあなたを追跡しても警告されません。システム設定でバックグラウンド位置情報を有効にしてください。 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 735264bd..e0af794c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -481,4 +481,8 @@ There have been issues while scanning bluetooth. Make sure that AirGuard has all required permissions and that Bluetooth is turned on. If this error still persists, try to restart your phone. Add a comment + Notification Permission disabled + Notifications are disabled for this app. You will not be warned when a tracker has been following you. To change this enable notifications for this app in your system settings. + Background Location Permission disabled + The background locations permission has not been granted for this app. You will not be warned when a tracker has been following you while this app is closed. To change this enable background locations for this app in your system settings. diff --git a/build.gradle b/build.gradle index 0144e1bb..d891b82a 100644 --- a/build.gradle +++ b/build.gradle @@ -2,11 +2,11 @@ buildscript { ext.kotlin_version = '2.2.10' ext.hilt_compiler_version = '2.57.1' - ext.hilt_version = '1.2.0' - ext.room_version = '2.7.2' + ext.hilt_version = '1.3.0' + ext.room_version = '2.8.0' ext.compose_version = '1.7.3' ext.about_libraries_version = '12.2.4' - ext.work_version = '2.10.3' + ext.work_version = '2.10.4' ext.ksp_version = '2.2.10-2.0.2' repositories { @@ -22,7 +22,7 @@ buildscript { classpath 'com.android.tools.build:gradle:8.13.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_compiler_version" - classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.9.3" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.9.4" classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libraries_version" classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:$ksp_version" classpath "org.jetbrains.compose:compose-gradle-plugin:$compose_version" diff --git a/fastlane/metadata/android/de-DE/changelogs/55.txt b/fastlane/metadata/android/de-DE/changelogs/55.txt new file mode 100644 index 00000000..0c8e9262 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/55.txt @@ -0,0 +1,3 @@ +VERBESSERT: Die App funktioniert mit weniger Berechtigungen (Funktionsumfang abhängig von den gewährten Berechtigungen). +VERBESSERT: Bessere Kompatibilität bei ungewöhnlichen Bildschirmformaten. +VERBESSERT: Fehlerbehebungen \ No newline at end of file diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index ea464aae..506c41cb 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,30 +1,30 @@ -With AirGuard, you get the anti-stalking protection you deserve! -The app scans your surroundings in the background to detect trackers like AirTags, Samsung SmartTags, or Google Find My Device trackers. If a tracker is following you, you will receive an instant notification. +Mit AirGuard bekommst du den Anti-Stalking-Schutz, den du verdienst! +Die App scannt im Hintergrund deine Umgebung, um Tracker wie AirTags, Samsung SmartTags oder Google Find My Device-Tracker zu erkennen. Wenn ein Tracker dir folgt, erhältst du sofort eine Benachrichtigung. -These trackers are often no larger than a coin and are unfortunately misused to secretly track people. Since each tracker works differently, you would typically need multiple apps to detect unwanted tracking. -AirGuard combines the detection of various trackers into a single app – keeping you protected with ease. +Diese Tracker sind oft nicht größer als eine Münze und werden leider missbraucht, um Menschen heimlich zu verfolgen. Da jeder Tracker anders funktioniert, bräuchtest du normalerweise mehrere Apps, um unerwünschtes Tracking zu erkennen. +AirGuard vereint die Erkennung verschiedener Tracker in einer einzigen App – so bleibst du einfach geschützt. -Once a tracker is detected, you can make it play a sound (for supported models) or perform a manual scan to locate it. If you find a tracker, we recommend disabling it to prevent further tracking of your location. +Sobald ein Tracker entdeckt wurde, kannst du ihn einen Ton abspielen lassen (für unterstützte Modelle) oder einen manuellen Scan durchführen, um ihn zu lokalisieren. Wenn du einen Tracker findest, empfehlen wir, ihn zu deaktivieren, damit er deinen Standort nicht weiter verfolgen kann. -The app stores location data exclusively on your device, allowing you to review where a tracker has followed you. Your personal data is never shared. +Die App speichert Standortdaten ausschließlich auf deinem Gerät, sodass du später nachvollziehen kannst, wo dich ein Tracker verfolgt hat. Deine persönlichen Daten werden niemals weitergegeben. -If no trackers are found, the app runs silently in the background and won’t bother you. +Wenn keine Tracker gefunden werden, läuft die App im Hintergrund und stört dich nicht. -

How does the app work?

-AirGuard uses Bluetooth to detect AirTags, Samsung SmartTags, and other trackers. All data is processed and stored locally on your device. -If a tracker is detected in at least three different locations, you will receive a warning. You can adjust the security level in the settings to receive even faster alerts. +

Wie funktioniert die App?

+AirGuard verwendet Bluetooth, um AirTags, Samsung SmartTags und andere Tracker zu erkennen. Alle Daten werden lokal auf deinem Gerät verarbeitet und gespeichert. +Wenn ein Tracker an mindestens drei verschiedenen Orten festgestellt wird, erhältst du eine Warnung. Du kannst das Sicherheitsniveau in den Einstellungen anpassen, um noch schneller benachrichtigt zu werden. -

Who are we?

-We are part of the Technical University of Darmstadt. This project is part of the scientific research conducted by the Secure Mobile Networking Lab. -Our goal is to protect people’s privacy and investigate how widespread the issue of tracker-based stalking is. +

Wer sind wir?

+Wir sind Teil der Technischen Universität Darmstadt. Dieses Projekt ist Teil der wissenschaftlichen Forschung des Secure Mobile Networking Lab. +Unser Ziel ist es, die Privatsphäre von Menschen zu schützen und zu untersuchen, wie weit verbreitet das Problem des tracker-basierten Stalkings ist. -You can voluntarily participate in an anonymous study to help us gain more insights into the usage and spread of these trackers. +Du kannst freiwillig an einer anonymen Studie teilnehmen, um uns mehr Einblicke in die Nutzung und Verbreitung dieser Tracker zu geben. -This app will never be monetized – there are no ads and no paid features. You will never be charged for using it. +Diese App wird niemals monetarisiert – es gibt keine Werbung und keine kostenpflichtigen Funktionen. Für die Nutzung fallen keine Kosten an. -Our privacy policy can be found here: +Unsere Datenschutzerklärung findest du hier: https://tpe.seemoo.tu-darmstadt.de/privacy-policy.html -

Legal Notice

-AirTag, Find My, and iOS are registered trademarks of Apple Inc. -This project is not affiliated with Apple Inc. \ No newline at end of file +

Rechtlicher Hinweis

+AirTag, Find My und iOS sind eingetragene Marken der Apple Inc. +Dieses Projekt ist nicht mit Apple Inc. verbunden diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt index 94a42307..cbd0fb5f 100644 --- a/fastlane/metadata/android/de-DE/short_description.txt +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -1 +1 @@ -AirGuard erkennt Tracker, die zum stalking eingesetzt werden und warnt dich \ No newline at end of file +AirGuard erkennt Tracker, die zum Stalking eingesetzt werden und warnt dich. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/55.txt b/fastlane/metadata/android/en-US/changelogs/55.txt new file mode 100644 index 00000000..a558e8fe --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/55.txt @@ -0,0 +1,3 @@ +IMPROVED: App works with less permissions (functionality limited depending on given permissions) +IMPROVED: better compatibility on unusual Screen Formats +IMPROVED: Bugfixes \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 6c3a3292..6e8da807 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,13 +1,30 @@ -With AirGuard you get the anti-stalking protection you deserve! -The app scans your surroundings in the background to find trackers such as AirTags, Samsung SmartTags or Google Find My Device trackers. If the app finds out that a tracker is following you, you will receive a notification. +With AirGuard, you get the anti-stalking protection you deserve! +The app scans your surroundings in the background to detect trackers like AirTags, Samsung SmartTags, or Google Find My Device trackers. If a tracker is following you, you will receive an instant notification. -These trackers are only the size of a coin and are often used to track people. Unfortunately, each tracker works differently, so you often need multiple apps to detect tracking. -AirGuard combines the detection of different trackers so you only need one app to keep you safe. +These trackers are often no larger than a coin and are unfortunately misused to secretly track people. Since each tracker works differently, you would typically need multiple apps to detect unwanted tracking. +AirGuard combines the detection of various trackers into a single app – keeping you protected with ease. -Once a tracker has been found, you can make it ring (doesn't work on all models) or go searching with a manual scan. -If you find a tracker, we advise you to deactivate it so that it no longer tracks your location. +Once a tracker is detected, you can make it play a sound (for supported models) or perform a manual scan to locate it. If you find a tracker, we recommend disabling it to prevent further tracking of your location. -The app only saves your location locally to show you later where a tracker has followed you. This app never shares personal data. +The app stores location data exclusively on your device, allowing you to review where a tracker has followed you. Your personal data is never shared. -If the app does not find any trackers, then the app will not bother you. +If no trackers are found, the app runs silently in the background and won’t bother you. +

How does the app work?

+AirGuard uses Bluetooth to detect AirTags, Samsung SmartTags, and other trackers. All data is processed and stored locally on your device. +If a tracker is detected in at least three different locations, you will receive a warning. You can adjust the security level in the settings to receive even faster alerts. + +

Who are we?

+We are part of the Technical University of Darmstadt. This project is part of the scientific research conducted by the Secure Mobile Networking Lab. +Our goal is to protect people’s privacy and investigate how widespread the issue of tracker-based stalking is. + +You can voluntarily participate in an anonymous study to help us gain more insights into the usage and spread of these trackers. + +This app will never be monetized – there are no ads and no paid features. You will never be charged for using it. + +Our privacy policy can be found here: +https://tpe.seemoo.tu-darmstadt.de/privacy-policy.html + +

Legal Notice

+AirTag, Find My, and iOS are registered trademarks of Apple Inc. +This project is not affiliated with Apple Inc. diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 060ec166..65124113 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Protect yourself from Apple's Find My Tracking \ No newline at end of file +AirGuard dectects trackers that are used for stalking and warns you. \ No newline at end of file