diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4179ac3c..63443d36 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -10,16 +10,34 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - name: Set up JDK - uses: actions/setup-java@v2 + - name: Set up JDK 17 and enable Gradle cache + uses: actions/setup-java@v3 with: - java-version: '11' - distribution: 'adopt' - cache: gradle + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + cache-dependency-path: | + **/gradle.lockfile + **/gradle-wrapper.properties + **/build.gradle + **/build.gradle.kts + **/settings.gradle + **/settings.gradle.kts - - name: Build app - uses: gradle/gradle-command-action@v2 + - name: Cache Gradle directories + uses: actions/cache@v4 with: - arguments: build + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties', '**/build.gradle', '**/build.gradle.kts', '**/settings.gradle', '**/settings.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for Gradle wrapper + run: chmod +x ./gradlew + + - name: Build app + run: ./gradlew build --no-daemon diff --git a/api.properties b/api.properties index bb91fe29..400e273b 100644 --- a/api.properties +++ b/api.properties @@ -1,2 +1,2 @@ -API_KEY="3acPl2TP.lAyeGkWWlqPrgfWI9WbzqKKHejOmahJ3" +API_KEY="VVZZfRjk.Ijo01HvEAbi7XpcNIrlQLyhnj0S3OLwh" 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 9f804f0a..d91ec9e3 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 55 - versionName "2.6.1" + versionCode 56 + versionName "2.6.2" buildConfigField "String", "API_KEY", apiProperties["API_KEY"] buildConfigField "String", "API_BASE_ADDRESS", apiProperties["API_BASE_ADDRESS"] @@ -92,8 +92,8 @@ 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.4' - implementation 'androidx.navigation:navigation-ui-ktx:2.9.4' + implementation 'androidx.navigation:navigation-fragment-ktx:2.9.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.9.5' 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' @@ -159,23 +159,23 @@ dependencies { // Integration with activities 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' + 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.9.1' + implementation 'androidx.compose.animation:animation:1.9.2' // Tooling support (Previews, etc.) - implementation 'androidx.compose.ui:ui-tooling:1.9.1' + implementation 'androidx.compose.ui:ui-tooling:1.9.2' // Integration with ViewModels implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4' // UI Tests - androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.9.1' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.9.2' // 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") + // 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") + // implementation("com.google.android.gms:play-services-base:18.9.0") } ksp { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3d4eaa83..4361d9f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + Timber.e(throwable, "Uncaught exception on thread ${thread.name}") } // Initiate the permanent background scan - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && SharedPrefs.usePermanentBluetoothScanner) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && SharedPrefs.usePermanentBluetoothScanner) { PermanentBluetoothScanner.scan() } 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 3e240275..aa630107 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") + fun observeDeviceBeacons(deviceAddress: String): Flow> + @Query("SELECT * FROM beacon WHERE deviceAddress LIKE :deviceAddress AND receivedAt >= :since ORDER BY receivedAt DESC") fun getDeviceBeaconsSince(deviceAddress: String, since: LocalDateTime): List 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 eb019aa4..6e0b8cec 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 @@ -129,4 +129,7 @@ interface DeviceDao { @Query("SELECT * FROM device WHERE deviceType = :deviceType AND lastSeen >= :since AND connectable = :connectableState LIMIT 1") fun getDeviceWithConnectableStateSince(deviceType: String, since: LocalDateTime, connectableState: Boolean): BaseDevice? + + @Query("SELECT * FROM device WHERE address LIKE :address LIMIT 1") + fun observeByAddress(address: String): Flow } \ 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 b778a14e..fa970f80 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 @@ -23,6 +23,7 @@ import de.seemoo.at_tracking_detection.database.models.device.types.SamsungTrack import de.seemoo.at_tracking_detection.database.models.device.types.SamsungTrackerType import de.seemoo.at_tracking_detection.database.models.device.types.Tile import de.seemoo.at_tracking_detection.database.models.device.types.Unknown +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.converter.DateTimeConverter @@ -129,8 +130,14 @@ data class BaseDevice( } else if (deviceType == DeviceType.GOOGLE_FIND_MY_NETWORK && subDeviceType != "UNKNOWN") { val subType = GoogleFindMyNetworkType.stringToSubType(subDeviceType) AppCompatResources.getDrawable(ATTrackingDetectionApplication.getAppContext(), GoogleFindMyNetworkType.drawableForSubType(subType, name)) - } - else { + } else if (ScanFragment.samsungSubDeviceTypeMap.containsKey(uniqueId)) { + val subType = ScanFragment.samsungSubDeviceTypeMap[uniqueId]!! + AppCompatResources.getDrawable(ATTrackingDetectionApplication.getAppContext(), SamsungTrackerType.drawableForSubType(subType)) + } else if (ScanFragment.googleSubDeviceTypeMap.containsKey(uniqueId)) { + val subType = ScanFragment.googleSubDeviceTypeMap[uniqueId]!! + val deviceNameFromCache = ScanFragment.deviceNameMap[uniqueId] + AppCompatResources.getDrawable(ATTrackingDetectionApplication.getAppContext(), GoogleFindMyNetworkType.drawableForSubType(subType, deviceNameFromCache)) + } else { device.getDrawable() } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/DeviceType.kt b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/DeviceType.kt index d6f28205..6ddd7b3b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/DeviceType.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/database/models/device/DeviceType.kt @@ -2,6 +2,7 @@ package de.seemoo.at_tracking_detection.database.models.device import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.database.models.device.types.* +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.SharedPrefs @@ -35,23 +36,6 @@ enum class DeviceType { } } - fun userReadableName(wrappedScanResult: ScanResultWrapper): String { - val deviceType: DeviceType = wrappedScanResult.deviceType - return when (deviceType) { - UNKNOWN -> Unknown.defaultDeviceName - AIRPODS -> AirPods.defaultDeviceName - AIRTAG -> AirTag.defaultDeviceName - APPLE -> AppleDevice.defaultDeviceName - FIND_MY -> AppleFindMy.defaultDeviceName - TILE -> Tile.defaultDeviceName - CHIPOLO -> Chipolo.defaultDeviceName - PEBBLEBEE -> PebbleBee.defaultDeviceName - SAMSUNG_TRACKER -> SamsungTracker.defaultDeviceName - SAMSUNG_FIND_MY_MOBILE -> SamsungFindMyMobile.defaultDeviceName - GOOGLE_FIND_MY_NETWORK -> GoogleFindMyNetwork.defaultDeviceName - } - } - fun getImageDrawable(wrappedScanResult: ScanResultWrapper): Int { val deviceType: DeviceType = wrappedScanResult.deviceType return when (deviceType) { @@ -63,9 +47,28 @@ enum class DeviceType { TILE -> R.drawable.ic_tile CHIPOLO -> R.drawable.ic_chipolo PEBBLEBEE -> R.drawable.ic_pebblebee_clip - SAMSUNG_TRACKER -> R.drawable.ic_smarttag_icon + SAMSUNG_TRACKER -> getSamsungDrawable(wrappedScanResult) SAMSUNG_FIND_MY_MOBILE -> R.drawable.ic_baseline_device_unknown_24 - GOOGLE_FIND_MY_NETWORK -> R.drawable.ic_chipolo + GOOGLE_FIND_MY_NETWORK -> getGoogleDrawable(wrappedScanResult) + } + } + + private fun getSamsungDrawable(wrappedScanResult: ScanResultWrapper): Int { + return if (ScanFragment.samsungSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { + val subType = ScanFragment.samsungSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! + SamsungTrackerType.drawableForSubType(subType) + } else { + R.drawable.ic_smarttag_icon + } + } + + private fun getGoogleDrawable(wrappedScanResult: ScanResultWrapper): Int { + return if (ScanFragment.googleSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { + val subType = ScanFragment.googleSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! + val deviceNameFromCache = ScanFragment.deviceNameMap[wrappedScanResult.uniqueIdentifier] + GoogleFindMyNetworkType.drawableForSubType(subType, deviceNameFromCache) + } else { + R.drawable.ic_chipolo } } 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 ebc6efeb..e6d570c8 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 getDeviceBeaconsFlow(deviceAddress: String): Flow> = + beaconDao.observeDeviceBeacons(deviceAddress) + fun getDeviceBeaconsSince(deviceAddress: String, since: LocalDateTime): List = beaconDao.getDeviceBeaconsSince(deviceAddress, since) 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 b616c4ba..a418b75d 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 @@ -47,6 +47,8 @@ class DeviceRepository @Inject constructor(private val deviceDao: DeviceDao) { fun getDevice(deviceAddress: String): BaseDevice? = deviceDao.getByAddress(deviceAddress) + fun observeDevice(deviceAddress: String): Flow = deviceDao.observeByAddress(deviceAddress) + val countNotTracking = deviceDao.getCountNotTracking(RiskLevelEvaluator.relevantTrackingDateForRiskCalculation) val countIgnored = deviceDao.getCountIgnored() 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 56da9818..ac6aab14 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 @@ -9,6 +9,8 @@ import android.os.Build import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.github.appintro.AppIntro import com.github.appintro.AppIntroFragment @@ -37,6 +39,15 @@ class OnboardingActivity : AppIntro() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + 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") + } + permission = intent.getStringExtra("permission") Timber.d("Onboarding started with: $permission") if (permission != null) { @@ -52,6 +63,15 @@ class OnboardingActivity : AppIntro() { } } + override fun onResume() { + super.onResume() + 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 in onResume") + } + } + override fun onDonePressed(currentFragment: Fragment?) { //Checks which permissions have given to store the default value for location access val locationPermissionState = 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 740d9caf..251c43a9 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 @@ -38,8 +38,9 @@ class DashboardRiskFragment : Fragment() { private var _binding: FragmentDashboardRiskBinding? = null private val binding get() = _binding!! - @Inject - lateinit var reviewController: ReviewController + // Google Play Review Controller: Only active in Google Play builds + // @Inject + // lateinit var reviewController: ReviewController override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -62,8 +63,9 @@ class DashboardRiskFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // Google Play Review Controller: Only active in Google Play builds // Increment app open count - reviewController.incrementAppOpenCount() + // reviewController.incrementAppOpenCount() val riskCard: MaterialCardView = view.findViewById(R.id.risk_card) riskCard.setOnClickListener { @@ -204,19 +206,21 @@ class DashboardRiskFragment : Fragment() { } } + // Google Play Review Controller: Only active in Google Play builds // Check if we should show review after data is loaded - checkAndShowReview() + // 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") - } - } + // 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") +// } +// } override fun onStart() { super.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 index b37c9c0e..7b0508ac 100644 --- 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 @@ -1,159 +1,160 @@ 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 +// Google Play Review Controller: Only active in Google Play builds +//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 index 88732e56..9ccffae4 100644 --- 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 @@ -1,20 +1,21 @@ 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 +// Google Play Review Controller: Only active in Google Play builds +//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/scan/ScanDistanceFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanDistanceFragment.kt index 90cd2b06..96f1186d 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanDistanceFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanDistanceFragment.kt @@ -123,8 +123,8 @@ class ScanDistanceFragment : Fragment() { viewModel.displayName.postValue(GoogleFindMyNetworkType.visibleStringFromSubtype(googleSubType)) } else { Timber.d("Display Name - Default") - binding.deviceTypeText.text = DeviceType.userReadableName( - latestWrappedScanResult!! + binding.deviceTypeText.text = DeviceType.userReadableNameDefault( + latestWrappedScanResult!!.deviceType ) } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanFragment.kt index e1929bf0..018e147b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/ScanFragment.kt @@ -115,8 +115,42 @@ class ScanFragment : Fragment() { } private fun toggleInfoLayoutVisibility(view: View) { - view.findViewById(R.id.info_layout).apply { - visibility = if (isVisible) View.GONE else View.VISIBLE + val infoLayout = view.findViewById(R.id.info_layout) + + val duration = 200L + val density = view.context.resources.displayMetrics.density + val slidePx = (-10 * density) + + if (infoLayout.isVisible) { + infoLayout.animate() + .alpha(0f) + .scaleX(0.95f) + .scaleY(0.95f) + .translationY(slidePx) + .setDuration(duration) + .withEndAction { + infoLayout.visibility = View.GONE + // reset properties for next show + infoLayout.alpha = 1f + infoLayout.scaleX = 1f + infoLayout.scaleY = 1f + infoLayout.translationY = 0f + } + .start() + } else { + infoLayout.alpha = 0f + infoLayout.scaleX = 0.95f + infoLayout.scaleY = 0.95f + infoLayout.translationY = slidePx + infoLayout.visibility = View.VISIBLE + + infoLayout.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .translationY(0f) + .setDuration(duration) + .start() } } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/SettingsFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/SettingsFragment.kt index fc69ea7a..f0a3f7d4 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/SettingsFragment.kt @@ -56,12 +56,24 @@ class SettingsFragment : PreferenceFragmentCompat() { entries.add(getString(R.string.samsung_find_my_mobile_name)) entryValues.add("samsung_find_my_mobile") } + + if (!entryValues.contains("apple_devices")) { + entries.add(getString(R.string.apple_device)) + entryValues.add("apple_devices") + } } else { // Remove samsung_find_my_mobile if present - val index = entryValues.indexOf("samsung_find_my_mobile") - if (index != -1) { - entries.removeAt(index) - entryValues.removeAt(index) + val samsungIndex = entryValues.indexOf("samsung_find_my_mobile") + if (samsungIndex != -1) { + entries.removeAt(samsungIndex) + entryValues.removeAt(samsungIndex) + } + + // Remove apple_devices if present + val appleIndex = entryValues.indexOf("apple_devices") + if (appleIndex != -1) { + entries.removeAt(appleIndex) + entryValues.removeAt(appleIndex) } } @@ -197,7 +209,7 @@ class SettingsFragment : PreferenceFragmentCompat() { "use_permanent_bluetooth_scanner" -> { if (SharedPrefs.usePermanentBluetoothScanner) { Timber.d("Enabled permanent bluetooth scanner!") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { try { GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { PermanentBluetoothScanner.scan() @@ -224,7 +236,7 @@ class SettingsFragment : PreferenceFragmentCompat() { if (SharedPrefs.advancedMode) { Timber.d("Enabled advanced mode!") findPreference("use_location")?.isVisible = true - findPreference("use_permanent_bluetooth_scanner")?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + findPreference("use_permanent_bluetooth_scanner")?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE findPreference("use_low_power_ble")?.isVisible = true findPreference("notification_priority_high")?.isVisible = true findPreference("show_onboarding")?.isVisible = true 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 3ac69fa5..4566424d 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 @@ -13,12 +13,14 @@ 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.util.SharedPrefs +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import javax.inject.Inject +import androidx.core.net.toUri class TrackingViewModel @Inject constructor( private val notificationRepository: NotificationRepository, @@ -54,8 +56,10 @@ class TrackingViewModel @Inject constructor( val isMapLoading = MutableLiveData(false) - val markerLocations: LiveData> = deviceAddress.map { - beaconRepository.getDeviceBeacons(it) + // Reactively update markers when beacons are written + // This is relevant for the case when a user very quickly opens the map from the manual scan while the device and beacons are still beeing written + val markerLocations: LiveData> = deviceAddress.switchMap { address -> + beaconRepository.getDeviceBeaconsFlow(address).asLiveData() } val amountBeacons: LiveData = markerLocations.map { @@ -71,52 +75,45 @@ class TrackingViewModel @Inject constructor( val deviceComment = MutableLiveData("") - fun loadDevice(address: String, deviceTypeOverride: DeviceType) = - deviceRepository.getDevice(address).also { device -> - this.device.postValue(device) - deviceType.value = deviceTypeOverride - - Timber.d("Set Device type: ${deviceType.value}") - - if (device != null) { - deviceType.value = device.device.deviceContext.deviceType // This line is still necessary for the Device List in Expert Mode - val deviceObserved = device.nextObservationNotification != null && device.nextObservationNotification!!.isAfter( - LocalDateTime.now()) - trackerObserved.postValue(deviceObserved) - deviceIgnored.postValue(device.ignore) - noLocationsYet.postValue(false) - connectable.postValue(device.device is Connectable) - 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 { - loadLastSeenTimes(device) + fun loadDevice(address: String, deviceTypeOverride: DeviceType) { + deviceAddress.postValue(address) + deviceType.postValue(deviceTypeOverride) + + viewModelScope.launch { + deviceRepository.observeDevice(address).collectLatest { dev -> + this@TrackingViewModel.device.postValue(dev) + + if (dev != null) { + deviceType.value = dev.device.deviceContext.deviceType + val deviceObserved = dev.nextObservationNotification != null && dev.nextObservationNotification!!.isAfter( + LocalDateTime.now()) + trackerObserved.postValue(deviceObserved) + deviceIgnored.postValue(dev.ignore) + noLocationsYet.postValue(false) + connectable.postValue(dev.device is Connectable) + canBeIgnored.postValue(deviceType.value!!.canBeIgnored(ConnectionState.OVERMATURE_OFFLINE)) + val notification = notificationRepository.notificationForDevice(dev).firstOrNull() + notification?.let { notificationId.postValue(it.notificationId) } + falseAlarm.postValue(notification?.falseAlarm ?: false) + + // Update last seen times based on current beacons + val beacons = beaconRepository.getDeviceBeacons(dev.address) + val lastSeenList = beacons.sortedByDescending { it.receivedAt }.take(5).map { beacon -> + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).format(beacon.receivedAt) + } + lastSeenTimes.postValue(lastSeenList) + expertMode.postValue(SharedPrefs.advancedMode) + deviceComment.postValue(dev.comment ?: "") + } else { + noLocationsYet.postValue(true) + deviceComment.postValue("") } - deviceComment.postValue(device.comment ?: "") - } else { - noLocationsYet.postValue(true) - deviceComment.postValue("") - } - showNfcHint.postValue(deviceType.value == DeviceType.AIRTAG) - if (deviceType.value != null) { - val websiteURL = DeviceManager.getWebsiteURL(deviceType.value!!) - manufacturerWebsiteUrl.postValue(websiteURL) - } else { - manufacturerWebsiteUrl.postValue("") + showNfcHint.postValue(deviceType.value == DeviceType.AIRTAG) + manufacturerWebsiteUrl.postValue(DeviceManager.getWebsiteURL(deviceType.value!!)) } } - - private fun loadLastSeenTimes(baseDevice: BaseDevice) { - val beacons = beaconRepository.getDeviceBeacons(baseDevice.address) - val lastSeenList = beacons.sortedByDescending { it.receivedAt }.take(5).map { beacon -> - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).format(beacon.receivedAt) - } - lastSeenTimes.postValue(lastSeenList) } fun toggleIgnoreDevice() { @@ -143,13 +140,12 @@ class TrackingViewModel @Inject constructor( fun clickOnWebsite(context: android.content.Context) { if (manufacturerWebsiteUrl.value != null) { Timber.d("Click on website: ${manufacturerWebsiteUrl.value}") - val webpage: Uri = Uri.parse(manufacturerWebsiteUrl.value) + val webpage: Uri = manufacturerWebsiteUrl.value!!.toUri() val intent = Intent(Intent.ACTION_VIEW, webpage) context.startActivity(intent) } } - // Add function to update comment and save to DB fun updateDeviceComment(newComment: String) { device.value?.let { baseDevice -> if (baseDevice.comment != newComment) { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/util/BindingAdapter.kt b/app/src/main/java/de/seemoo/at_tracking_detection/util/BindingAdapter.kt index b00d2b4b..856ba64b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/util/BindingAdapter.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/util/BindingAdapter.kt @@ -9,7 +9,6 @@ import androidx.recyclerview.widget.RecyclerView 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.models.device.types.GoogleFindMyNetwork import de.seemoo.at_tracking_detection.database.models.device.types.GoogleFindMyNetworkType import de.seemoo.at_tracking_detection.database.models.device.types.SamsungTrackerType import de.seemoo.at_tracking_detection.ui.scan.ScanFragment @@ -51,26 +50,11 @@ fun setDeviceDrawable(imageView: ImageView, wrappedScanResult: ScanResultWrapper val deviceRepository = ATTrackingDetectionApplication.getCurrentApp().deviceRepository val deviceFromDb = deviceRepository.getDevice(wrappedScanResult.uniqueIdentifier) - val drawableResId = if (deviceFromDb != null && deviceFromDb.subDeviceType != "UNKNOWN" && deviceFromDb.deviceType == DeviceType.SAMSUNG_TRACKER) { - val subTypeString = deviceFromDb.subDeviceType - val subType = SamsungTrackerType.stringToSubType(subTypeString) - SamsungTrackerType.drawableForSubType(subType) - } else if (deviceFromDb != null && deviceFromDb.deviceType == DeviceType.GOOGLE_FIND_MY_NETWORK) { - val subTypeString = deviceFromDb.subDeviceType - val subType = GoogleFindMyNetworkType.stringToSubType(subTypeString) - GoogleFindMyNetworkType.drawableForSubType(subType, deviceFromDb.name) - } else if (ScanFragment.samsungSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { - val subType = ScanFragment.samsungSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! - SamsungTrackerType.drawableForSubType(subType) - } else if (ScanFragment.googleSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { - val subType = ScanFragment.googleSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! - val deviceNameFromCache = ScanFragment.deviceNameMap[wrappedScanResult.uniqueIdentifier] - GoogleFindMyNetworkType.drawableForSubType(subType, deviceNameFromCache) + val drawable = if (deviceFromDb != null) { + deviceFromDb.getDrawable() } else { - DeviceType.getImageDrawable(wrappedScanResult) + DeviceType.getImageDrawable(wrappedScanResult).let { ContextCompat.getDrawable(imageView.context, it) } } - - val drawable = ContextCompat.getDrawable(imageView.context, drawableResId) imageView.setImageDrawable(drawable) } @@ -79,28 +63,41 @@ fun setDeviceName(textView: TextView, wrappedScanResult: ScanResultWrapper) { val deviceRepository = ATTrackingDetectionApplication.getCurrentApp().deviceRepository val deviceFromDb = deviceRepository.getDevice(wrappedScanResult.uniqueIdentifier) - if (deviceFromDb?.name != null) { - textView.text = deviceFromDb.getDeviceNameWithID() - } else if (deviceFromDb != null && deviceFromDb.subDeviceType != "UNKNOWN" && deviceFromDb.deviceType == DeviceType.SAMSUNG_TRACKER) { - val subTypeString = deviceFromDb.subDeviceType - val subType = SamsungTrackerType.stringToSubType(subTypeString) - ScanFragment.samsungSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier] = subType - textView.text = SamsungTrackerType.visibleStringFromSubtype(subType) - } else if (deviceFromDb != null && deviceFromDb.deviceType == DeviceType.GOOGLE_FIND_MY_NETWORK) { - val subTypeString = deviceFromDb.subDeviceType - val subType = GoogleFindMyNetworkType.stringToSubType(subTypeString) - ScanFragment.googleSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier] = subType - textView.text = GoogleFindMyNetworkType.visibleStringFromSubtype(subType) - } else if (ScanFragment.samsungSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { - val subType = ScanFragment.samsungSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! - textView.text = SamsungTrackerType.visibleStringFromSubtype(subType) - } else if (ScanFragment.googleSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { - val subType = ScanFragment.googleSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! - textView.text = GoogleFindMyNetworkType.visibleStringFromSubtype(subType) - } else if (ScanFragment.deviceNameMap.containsKey(wrappedScanResult.uniqueIdentifier)) { - textView.text = ScanFragment.deviceNameMap[wrappedScanResult.uniqueIdentifier] + textView.text = if (deviceFromDb != null) { + // Case: device is in DB + + if (deviceFromDb.name != null) { + deviceFromDb.getDeviceNameWithID() + } else if (deviceFromDb.deviceType == DeviceType.SAMSUNG_TRACKER && deviceFromDb.subDeviceType != "UNKNOWN") { + val subTypeString = deviceFromDb.subDeviceType + val subType = SamsungTrackerType.stringToSubType(subTypeString) + ScanFragment.samsungSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier] = subType + SamsungTrackerType.visibleStringFromSubtype(subType) + } else if (deviceFromDb.deviceType == DeviceType.GOOGLE_FIND_MY_NETWORK) { + val subTypeString = deviceFromDb.subDeviceType + val subType = GoogleFindMyNetworkType.stringToSubType(subTypeString) + ScanFragment.googleSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier] = subType + GoogleFindMyNetworkType.visibleStringFromSubtype(subType) + } else { + // Fallback + DeviceType.userReadableNameDefault(wrappedScanResult.deviceType) + } } else { - textView.text = DeviceType.userReadableName(wrappedScanResult) + // Case: device ist not in DB + // There is a possibility that the device has been determined. In that case this is only saved in the temporary map + + if (ScanFragment.samsungSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { + val subType = ScanFragment.samsungSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! + SamsungTrackerType.visibleStringFromSubtype(subType) + } else if (ScanFragment.googleSubDeviceTypeMap.containsKey(wrappedScanResult.uniqueIdentifier)) { + val subType = ScanFragment.googleSubDeviceTypeMap[wrappedScanResult.uniqueIdentifier]!! + GoogleFindMyNetworkType.visibleStringFromSubtype(subType) + } else if (ScanFragment.deviceNameMap.containsKey(wrappedScanResult.uniqueIdentifier)) { + ScanFragment.deviceNameMap[wrappedScanResult.uniqueIdentifier] + } else { + // Fallback + DeviceType.userReadableNameDefault(wrappedScanResult.deviceType) + } } } 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 bf760e2b..bd8bd690 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 @@ -497,8 +497,11 @@ object Utility { val deviceType = wrappedScanResult.deviceType val securityLevel = SharedPrefs.riskSensitivity + // Skip Samsung Find My Mobile and Apple devices if security level is set to high as this causes a lot of false positives if (deviceType == DeviceType.SAMSUNG_FIND_MY_MOBILE) { return securityLevel != "high" + } else if (deviceType == DeviceType.APPLE) { + return securityLevel != "high" } return false diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt b/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt index 3128c952..027f4d53 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt @@ -8,7 +8,6 @@ import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication -import de.seemoo.at_tracking_detection.detection.BackgroundBluetoothScanner import de.seemoo.at_tracking_detection.detection.PermanentBluetoothScanner import de.seemoo.at_tracking_detection.detection.ScanBluetoothWorker import de.seemoo.at_tracking_detection.util.SharedPrefs @@ -16,6 +15,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers import timber.log.Timber import java.util.concurrent.TimeUnit import kotlin.coroutines.CoroutineContext @@ -25,37 +25,67 @@ class ScheduleWorkersReceiver: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { Timber.d("Broadcast received ${intent?.action}") - if (intent?.action == "AlarmManagerWakeUp_Schedule_BackgroundScan") { - // The app has been launched because no scan was performed since two hours - val backgroundWorkScheduler = ATTrackingDetectionApplication.getCurrentApp().backgroundWorkScheduler - //Schedule the periodic scan worker which runs every 15min - backgroundWorkScheduler.launch() - if (SharedPrefs.shareData) { - backgroundWorkScheduler.scheduleShareData() - } - BackgroundWorkScheduler.scheduleAlarmWakeupIfScansFail() - }else { - // action = AlarmManagerWakeUp_Perform_BackgroundScan - // The app has been launched to perform another scan - BackgroundWorkScheduler.scheduleScanWithAlarm() - @OptIn(DelicateCoroutinesApi::class) - goAsync { - Timber.d("Running scan launched from Alert") - BackgroundBluetoothScanner.scanInBackground(startedFrom = "ScheduleWorkersReceiver") + val action = intent?.action + val backgroundWorkScheduler = ATTrackingDetectionApplication.getCurrentApp().backgroundWorkScheduler + + // Keep the broadcast short: schedule alarms inline; offload WorkManager to a separate thread and finish quickly. + goAsync(Dispatchers.Default) { + when (action) { + // App woke up to make sure background schedule exists. Keep it light. + "AlarmManagerWakeUp_Schedule_BackgroundScan" -> { + // Offload WorkManager work + Thread { + try { + backgroundWorkScheduler.launch() + if (SharedPrefs.shareData) { + backgroundWorkScheduler.scheduleShareData() + } + } catch (t: Throwable) { + Timber.w(t, "Failed scheduling periodic work from receiver") + } finally { + BackgroundWorkScheduler.scheduleAlarmWakeupIfScansFail() + } + }.start() + } + + // Our exact/alarm fired to perform a scan: enqueue work, don't run the scan inline. + "AlarmManagerWakeUp_Perform_BackgroundScan" -> { + // Reschedule next alarm now (cheap) + BackgroundWorkScheduler.scheduleScanWithAlarm() + // Offload WorkManager enqueue to separate thread so we return fast + Thread { + try { + backgroundWorkScheduler.scheduleImmediateBackgroundScan() + } catch (t: Throwable) { + Timber.w(t, "Failed to enqueue immediate scan from receiver") + } + }.start() + } + + // System broadcasts: keep receiver fast; just (re)establish lightweight schedules. + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { + // Only (re)schedule alarms on boot/package replace. Avoid WorkManager initialization here. + BackgroundWorkScheduler.scheduleScanWithAlarm() + BackgroundWorkScheduler.scheduleAlarmWakeupIfScansFail() + } + + else -> Timber.w("Unhandled broadcast action: $action") } } - // Initiate the permanent background scanner. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && SharedPrefs.usePermanentBluetoothScanner) { - goAsync { - Timber.d("Attempting to start PermanentBluetoothScanner from ScheduleWorkersReceiver") - PermanentBluetoothScanner.scan() + // Start the permanent scanner in a detached way + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && SharedPrefs.usePermanentBluetoothScanner) { + goAsync(Dispatchers.Default) { + try { + Timber.d("Attempting to start PermanentBluetoothScanner from ScheduleWorkersReceiver") + PermanentBluetoothScanner.scan() + } catch (t: Throwable) { + Timber.w(t, "Failed starting PermanentBluetoothScanner from receiver") + } } } } - - companion object { const val OBSERVATION_DURATION = 1L // in hours const val OBSERVATION_DELTA = 30L // in minutes diff --git a/app/src/main/res/layout/fragment_scan.xml b/app/src/main/res/layout/fragment_scan.xml index 53ef3186..e2e32cd4 100644 --- a/app/src/main/res/layout/fragment_scan.xml +++ b/app/src/main/res/layout/fragment_scan.xml @@ -67,6 +67,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:nestedScrollingEnabled="false" + android:clipToPadding="false" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:setAdapter="@{adapter_high_risk}" /> @@ -74,6 +75,7 @@ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9df18a39..e28a003e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,7 +2,7 @@ AirGuard Ignorierte Geräte - Übersicht + Startseite Einstellungen Debug Feedback @@ -31,7 +31,7 @@ Hintergrundscan deaktivieren Diese Option deaktiviert den Hintergrundscan. Achtung: Wenn diese Option aktiviert ist, bekommst du keine Benachrichtigungen mehr, wenn dir ein Tracker folgt. Permanenter Scanner - Nutze einen permanenten Hintergrund-Scan. Diese Feature sorgt für akkuratere Scans, könnte aber in Ausnahmefällen die Stabilität der App beeinflussen. + Nutze einen permanenten Hintergrund-Scan. Diese Feature sorgt für akkuratere Scans, könnte aber die Stabilität der App beeinflussen. Im Zweifel: Deaktiviert lassen Aktivieren, um detailliertere Standortinformationen bei einer Warnung zu erhalten Standort nutzen Verbundene Geräte anzeigen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0af794c..edfd53ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ AirGuard Ignored - Dashboard + Home Settings Debug Feedback @@ -35,7 +35,7 @@ Deactivate background scan This deactivates AirGuard running in the background. Please be aware: You will not be warned if a tracker is following you anymore. Permanent scanning - Use a permanent background scanner. Enabling this feature results in more accurate results, but in rare cases might affect the stability of the app. + Use a permanent background scanner. Enabling this feature results in more accurate results, but it might affect the stability of the app. When in doubt: leave this option disabled. Discovered: Beacons Times seen diff --git a/build.gradle b/build.gradle index d891b82a..6fa59a03 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = '2.2.10' - ext.hilt_compiler_version = '2.57.1' + ext.hilt_compiler_version = '2.57.2' ext.hilt_version = '1.3.0' - ext.room_version = '2.8.0' + ext.room_version = '2.8.1' ext.compose_version = '1.7.3' ext.about_libraries_version = '12.2.4' - ext.work_version = '2.10.4' + ext.work_version = '2.10.5' 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.4" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.9.5" 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/56.txt b/fastlane/metadata/android/de-DE/changelogs/56.txt new file mode 100644 index 00000000..b8fdd870 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/56.txt @@ -0,0 +1,4 @@ +Änderung: Apple-Geräte (MacBooks, iPhones) sind standardmäßig deaktiviert, wenn die Sensitivität nicht auf "hoch" eingestellt ist +Verbessert: Absturz behoben, der auf neueren Android-Geräten auftreten konnte +Verbessert: Leistungsverbesserungen im Hintergrund +Verbessert: Fehlerbehebungen \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/56.txt b/fastlane/metadata/android/en-US/changelogs/56.txt new file mode 100644 index 00000000..4b0977ae --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/56.txt @@ -0,0 +1,4 @@ +CHANGE: Apple Devices (MacBooks, iPhones) are disabled by default when risk sensitivity is not set to high +IMPROVED: Fix crash that could occur on newer Android devices +IMPROVED: Background Performance Improvements +IMPROVED: Bugfixes \ No newline at end of file