diff --git a/app/build.gradle b/app/build.gradle index d9bf360..dd88b8a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,11 +99,11 @@ dependencies { implementation 'androidx.vectordrawable:vectordrawable:1.1.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0' implementation 'androidx.navigation:navigation-ui-ktx:2.6.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.3.1' + implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' @@ -126,7 +126,9 @@ dependencies { implementation "com.mikepenz:aboutlibraries:$about_libraries_version" - implementation 'io.noties.markwon:core:4.6.2' + implementation 'com.github.mukeshsolanki:MarkdownView-Android:2.0.0' + + implementation 'com.github.bumptech.glide:glide:4.16.0' kapt "com.google.dagger:hilt-compiler:$hilt_version" @@ -146,7 +148,7 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' //Finds memory leaks while running the app in Debug mode // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' @@ -161,7 +163,7 @@ dependencies { // Tooling support (Previews, etc.) implementation 'androidx.compose.ui:ui-tooling:1.4.3' // Integration with ViewModels - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' // UI Tests androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.4.3' // When using a MDC theme diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ea01a8..505363c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + @@ -29,24 +30,25 @@ - + tools:targetApi="tiramisu" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="false"> + android:parentActivityName=".ui.MainActivity" + android:exported="false" /> - + - + - + + android:enabled="true" + android:exported="false"/> () - open fun getLastLocation(checkRequirements: Boolean = true): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + fun getLastLocation(checkRequirements: Boolean = true): Location? { + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return null } @@ -33,12 +40,19 @@ open class LocationProvider @Inject constructor( } /** - * Fetches the most recent location from network and gps and returns the one that has been recveived more recently + * Fetches the most recent location from network and gps and returns the one that has been received more recently * @return the most recent location across multiple providers */ - @SuppressLint("InlinedApi") // Suppressed, because we use a custom version provider which is injectable for testing private fun getLastLocationFromAnyProvider(checkRequirements: Boolean): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return null } @@ -61,53 +75,46 @@ open class LocationProvider @Inject constructor( } private fun legacyGetLastLocationFromAnyProvider(checkRequirements: Boolean): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Check for location permission + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return null } - // On older versions we use both providers to get the best location signal + // Get the last known locations from both providers val networkLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + val gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - val gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - - if (gpsLocation != null && networkLocation != null) { - // Got to past locations, lets check which passes our requirements - val gpsRequirements = locationMatchesMinimumRequirements(gpsLocation) - val networkRequirements = locationMatchesMinimumRequirements(networkLocation) - if (gpsRequirements && networkRequirements) { - // Check which one is more current - if (gpsLocation.time > networkLocation.time) { - return gpsLocation - }else { - return networkLocation - } - }else if (gpsRequirements) { - // Only GPS satisfies the requirements. Return it - return gpsLocation - }else if (networkRequirements) { - // Only network satisfies. Return it - return networkLocation - }else if (!checkRequirements) { - if (gpsLocation.time > networkLocation.time) { - return gpsLocation - } - return networkLocation - } - }else if (gpsLocation != null && locationMatchesMinimumRequirements(gpsLocation)) { - // Only gps satisfies and network does not exist - return gpsLocation + // If both locations are available, return the one that is more current and meets the minimum requirements + if (networkLocation != null && gpsLocation != null) { + val bestLocation = if (gpsLocation.time > networkLocation.time) gpsLocation else networkLocation + if (locationMatchesMinimumRequirements(bestLocation)) { + return bestLocation } } + // If only one location is available, return it if it meets the minimum requirements if (networkLocation != null && locationMatchesMinimumRequirements(networkLocation)) { return networkLocation - }else if (!checkRequirements) { - return networkLocation + } + if (gpsLocation != null && locationMatchesMinimumRequirements(gpsLocation)) { + return gpsLocation } - Timber.d("No last know location matched the requirements") - return null + // If neither location meets the minimum requirements, return null + if (checkRequirements) { + return null + } + + // If no location requirements are specified, return the last known location from either provider, or null if none are available + return networkLocation ?: gpsLocation } private fun getSecondsSinceLocation(location: Location): Long { @@ -130,27 +137,38 @@ open class LocationProvider @Inject constructor( * @param timeoutMillis: After the timeout the last location will be returned no matter if it matches the requirements or not * @return the last known location if this already satisfies our requirements */ - @SuppressLint("InlinedApi") // Suppressed, because we use a custom version provider which is injectable for testing - open fun lastKnownOrRequestLocationUpdates(locationRequester: LocationRequester, timeoutMillis: Long?): Location? { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + open fun lastKnownOrRequestLocationUpdates( + locationRequester: LocationRequester, + timeoutMillis: Long? = null + ): Location? { + // Check for location permission + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED) { return null } + // Get the last known location val lastLocation = getLastLocation() + + // If the last location is available and meets the minimum requirements, return it if (lastLocation != null && locationMatchesMinimumRequirements(lastLocation)) { return lastLocation } + // Add the location requester to the list of active requesters this.locationRequesters.add(locationRequester) - // The fused location provider does not work reliably with Samsung + Android 12 - // We just stay with the legacy location, because this just works + // Request location updates from all enabled providers requestLocationUpdatesFromAnyProvider() + // If a timeout is specified, set a timeout for the location update if (timeoutMillis != null) { - setTimeoutForLocationUpdate(requester = locationRequester, timeoutMillis= timeoutMillis) + setTimeoutForLocationUpdate(requester = locationRequester, timeoutMillis = timeoutMillis) } + // Return null, since we don't have a location immediately available return null } @@ -162,45 +180,58 @@ open class LocationProvider @Inject constructor( * @param timeoutMillis milliseconds after which the timeout will be executed */ private fun setTimeoutForLocationUpdate(requester: LocationRequester, timeoutMillis: Long) { - val handler = Handler(Looper.getMainLooper()) - - val runnable = kotlinx.coroutines.Runnable { - if (this@LocationProvider.locationRequesters.size == 0) { - // The location was already returned + // Create a runnable to handle the timeout + val runnable = Runnable { + // If the location requester list is empty, the location has already been returned + if (this@LocationProvider.locationRequesters.isEmpty()) { return@Runnable } + // Log the timeout and get the last known location, regardless of whether it meets the requirements Timber.d("Location request timed out") val lastLocation = this@LocationProvider.getLastLocation(checkRequirements = false) + + // If the last location is available, notify the requester lastLocation?.let { - requester.receivedAccurateLocationUpdate(location = lastLocation) + requester.receivedAccurateLocationUpdate(location = it) } + + // If there is only one requester left, stop location updates and clear the list if (this@LocationProvider.locationRequesters.size == 1) { this@LocationProvider.stopLocationUpdates() this@LocationProvider.locationRequesters.clear() - }else { + } else { + // Otherwise, remove the requester from the list this@LocationProvider.locationRequesters.remove(requester) } } + // Schedule the runnable to be executed after the timeout period + val handler = Handler(Looper.getMainLooper()) handler.postDelayed(runnable, timeoutMillis) + + // Log the timeout settings Timber.d("Location request timeout set to $timeoutMillis") } - private fun requestLocationUpdatesFromAnyProvider() { - if (ContextCompat.checkSelfPermission(ATTrackingDetectionApplication.getAppContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Check for location permission + if (ContextCompat.checkSelfPermission( + ATTrackingDetectionApplication.getAppContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { return } - Timber.d("Requesting location updates") - val gpsProviderEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) - val networkProviderEnabled = - locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + // Get the list of enabled location providers + val enabledProviders = locationManager.allProviders + .filter { locationManager.isProviderEnabled(it) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && locationManager.isProviderEnabled(LocationManager.FUSED_PROVIDER)) { + // Request location updates from all enabled providers + enabledProviders.forEach { locationManager.requestLocationUpdates( - LocationManager.FUSED_PROVIDER, + it, MIN_UPDATE_TIME_MS, MIN_DISTANCE_METER, this, @@ -208,43 +239,14 @@ open class LocationProvider @Inject constructor( ) } - if (networkProviderEnabled) { - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - MIN_UPDATE_TIME_MS, - MIN_DISTANCE_METER, - this, - handler.looper - ) - } - - if (gpsProviderEnabled) { - // Using GPS and Network provider, because the GPS provider does notwork indoors (it will never call the callback) - locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, - MIN_UPDATE_TIME_MS, - MIN_DISTANCE_METER, - this, - handler.looper - ) - } - - if (!networkProviderEnabled && !gpsProviderEnabled) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (!locationManager.isProviderEnabled(LocationManager.FUSED_PROVIDER)) { - // Error - Timber.e("ERROR: No location provider available") - stopLocationUpdates() - } - }else { - //Error - Timber.e("ERROR: No location provider available") - stopLocationUpdates() - } + // If no location providers are enabled, log an error and stop location updates + if (enabledProviders.isEmpty()) { + Timber.e("ERROR: No location provider available") + stopLocationUpdates() } } - fun stopLocationUpdates() { + private fun stopLocationUpdates() { locationManager.removeUpdates(this) } @@ -274,9 +276,6 @@ open class LocationProvider @Inject constructor( } } - @Deprecated("Deprecated in Java") - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - // Android Phones with SDK < 30 need these methods override fun onProviderEnabled(provider: String) {} 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 e037938..7871459 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 @@ -90,11 +90,7 @@ class NotificationBuilder @Inject constructor( context, code, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } + PendingIntent.FLAG_IMMUTABLE ) } @@ -270,6 +266,20 @@ class NotificationBuilder @Inject constructor( } + fun buildObserveTrackerFailedNotification(notificationId: Int): Notification { + val bundle: Bundle = Bundle().apply { putInt("notificationId", notificationId) } + + return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID) + .setContentTitle(context.getString(R.string.notification_observe_tracker_title_base)) + .setContentText(context.getString(R.string.notification_observe_tracker_error)) + .setPriority(getNotificationPriority()) + .setContentIntent(pendingNotificationIntent(bundle, notificationId)) + .setCategory(Notification.CATEGORY_ERROR) + .setSmallIcon(R.drawable.ic_scan_icon) + .setAutoCancel(true) + .build() + } + fun buildBluetoothErrorNotification(): Notification { val notificationId = -100 val bundle: Bundle = Bundle().apply { putInt("notificationId", notificationId) } 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 c34ebbe..d9749dc 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 @@ -70,6 +70,20 @@ class NotificationService @Inject constructor( } } + @SuppressLint("MissingPermission") + fun sendObserveTrackerFailedNotification() { + val notificationId = generateNotificationId() + with(notificationManagerCompat) { + if (this.areNotificationsEnabled()) { + notify( + OBSERVE_TRACKER_NOTIFICATION_TAG, + notificationId, + notificationBuilder.buildObserveTrackerFailedNotification(notificationId) + ) + } + } + } + /* @SuppressLint("MissingPermission") suspend fun sendObserveTrackerNotification(baseDevice: BaseDevice) { @@ -162,28 +176,26 @@ class NotificationService @Inject constructor( } fun setup() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Timber.d("Setting up NotificationManager") - // Register the channel with the system - val channel = NotificationChannelCompat.Builder( - NotificationConstants.CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_HIGH - ) - .setName(NotificationConstants.NOTIFICATION_CHANNEL_NAME) - .build() - - notificationManagerCompat.createNotificationChannel(channel) - - //Register the info channel - val infoChannel = NotificationChannelCompat.Builder( - NotificationConstants.INFO_CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_LOW - ) - .setName(NotificationConstants.NOTIFICATION_CHANNEL_INFO) - .build() - - notificationManagerCompat.createNotificationChannel(infoChannel) - } + Timber.d("Setting up NotificationManager") + // Register the channel with the system + val channel = NotificationChannelCompat.Builder( + NotificationConstants.CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_HIGH + ) + .setName(NotificationConstants.NOTIFICATION_CHANNEL_NAME) + .build() + + notificationManagerCompat.createNotificationChannel(channel) + + //Register the info channel + val infoChannel = NotificationChannelCompat.Builder( + NotificationConstants.INFO_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_LOW + ) + .setName(NotificationConstants.NOTIFICATION_CHANNEL_INFO) + .build() + + notificationManagerCompat.createNotificationChannel(infoChannel) } companion object { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/statistics/api/Api.kt b/app/src/main/java/de/seemoo/at_tracking_detection/statistics/api/Api.kt index 27afbb2..0e69c66 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/statistics/api/Api.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/statistics/api/Api.kt @@ -28,6 +28,17 @@ interface Api { @Header("User-Agent") userAgent: String = USER_AGENT ): Response + /** + * Deletes the study data related to the given token. + */ + @Headers("Authorization: Api-Key $API_KEY") + @DELETE("delete_study_data") + suspend fun deleteStudyData( + @Header("token") token: String, + @Header("X-Timezone") timezone: String = TIME_ZONE, + @Header("User-Agent") userAgent: String = USER_AGENT + ): Response + @GET("ping") suspend fun ping( @Header("X-Timezone") timezone: String = TIME_ZONE, diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/MarkdownViewerActivity.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/MarkdownViewerActivity.kt deleted file mode 100644 index 244dd9d..0000000 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/MarkdownViewerActivity.kt +++ /dev/null @@ -1,45 +0,0 @@ -package de.seemoo.at_tracking_detection.ui - -import android.os.Bundle -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import de.seemoo.at_tracking_detection.R -import io.noties.markwon.Markwon - -class MarkdownViewerActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_markdown_viewer) - - val markdown = """ - # Hello Markdown - - This is a sample Markdown file rendered using Markwon library in Kotlin. - - - List item 1 - - List item 2 - - List item 3 - - **Bold Text** - - *Italic Text* - - ![Image](https://example.com/image.jpg) - - `Inline Code` - - ```kotlin - fun main() { - println("Hello, Markdown!") - } - ``` - """.trimIndent() - - val markwon = Markwon.builder(this) - .build() - - val markdownTextView = findViewById(R.id.markdownTextView) - markwon.setMarkdown(markdownTextView, markdown) - } -} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/OnboardingActivity.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/OnboardingActivity.kt index 31af1aa..c352ed1 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 @@ -218,9 +218,7 @@ class OnboardingActivity : AppIntro() { notificationSlide(slideNumber + 3) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - addSlide(IgnoreBatteryOptimizationFragment.newInstance()) - } + addSlide(IgnoreBatteryOptimizationFragment.newInstance()) addSlide(ShareDataFragment.newInstance()) diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt new file mode 100644 index 0000000..848aa18 --- /dev/null +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/Article.kt @@ -0,0 +1,96 @@ +package de.seemoo.at_tracking_detection.ui.dashboard + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import de.seemoo.at_tracking_detection.ATTrackingDetectionApplication +import de.seemoo.at_tracking_detection.R +import timber.log.Timber +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +data class Article( + val title: String, + val author: String, + val readingTime: Int, + val previewText: String, + val cardColor: String, + val preview_image: String, // TODO: Rename when in production to PreviewImage, also in JSON + val filename: String +) + +fun parseArticles(jsonString: String): List
{ + val gson = Gson() + val listType = object : TypeToken>() {}.type + val articleMap: Map = gson.fromJson(jsonString, listType) + return articleMap.values.toList() +} + +fun getURL(filename: String): String { + return "https://tpe.seemoo.tu-darmstadt.de/articles/$filename" +} + +fun downloadJson(): String { + val url = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.article_download_url) + + val articleOfflineTitle = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.article_offline_header) + val articleOfflineText = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.article_offline_text) + val iveGotANotification = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.i_got_a_notification_what_should_i_do) + val searchManually = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.notification_help) + val iCanNotFindTracker = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.i_cannot_find_the_tracker) + val findTackerHelp = ATTrackingDetectionApplication.getAppContext().resources.getString(R.string.find_tracker_help) + + val errorReturnValue = """{ + "article0": { + "title": "$articleOfflineTitle", + "author": "Dennis Arndt", + "readingTime": 0, + "previewText": "$articleOfflineText", + "cardColor": "blue_card_background", + "filename": "" + }, + "article1": { + "title": "$iveGotANotification", + "author": "Alexander Heinrich", + "readingTime": 0, + "previewText": "$searchManually", + "cardColor": "gray_card_background", + "filename": "" + }, + "article2": { + "title": "$iCanNotFindTracker", + "author": "Alexander Heinrich", + "readingTime": 0, + "previewText": "$findTackerHelp", + "cardColor": "gray_card_background", + "filename": "" + } + } + """.trimIndent() + + val connection = URL(url).openConnection() as HttpURLConnection + + return try { + connection.requestMethod = "GET" + val responseCode = connection.responseCode + + if (responseCode == HttpURLConnection.HTTP_OK) { + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val response = StringBuilder() + var inputLine: String? + while (reader.readLine().also { inputLine = it } != null) { + response.append(inputLine) + } + reader.close() + response.toString() + } else { + errorReturnValue + } + } catch (e: Exception) { + Timber.e(e) + errorReturnValue + } finally { + connection.disconnect() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt new file mode 100644 index 0000000..cb2d9a6 --- /dev/null +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/ArticleFragment.kt @@ -0,0 +1,85 @@ +package de.seemoo.at_tracking_detection.ui.dashboard + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import java.net.URL +import com.mukesh.MarkDown +import de.seemoo.at_tracking_detection.R +import timber.log.Timber + + +class ArticleFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the fragment layout + val view = inflater.inflate(R.layout.fragment_article, container, false) + + fun errorHandling() { + Toast.makeText(requireContext(), "No internet connection. Cannot load article.", Toast.LENGTH_SHORT).show() + } + + val titleTextView = view.findViewById(R.id.article_title) + val authorTextView = view.findViewById(R.id.article_author) + val markdownView = view.findViewById(R.id.markdown_view) + val articleReadingTimeView = view.findViewById(R.id.article_reading_time) + + val title = arguments?.getString("title") + val author = arguments?.getString("author") + val readingTime = arguments?.getInt("readingTime") + val filename = arguments?.getString("filename") + + if (filename == null) { + Timber.e("Filename is null") + errorHandling() + return view + } + + val url = getURL(filename) + + titleTextView.text = title + authorTextView.text = author + articleReadingTimeView.text = context?.getString(R.string.article_reading_time, readingTime) + + val modifier = Modifier.fillMaxSize() + + val connectivityManager = context?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + + if ((networkCapabilities != null) && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + try { + markdownView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MarkDown( + url = URL(url), + modifier = modifier + ) + } + } + } catch (e: Exception) { + Timber.d(e) + errorHandling() + } + + } else { + errorHandling() + } + + return view + } +} + 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 07c8a04..1ca69b9 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,18 +1,30 @@ package de.seemoo.at_tracking_detection.ui.dashboard +import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections 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.R import de.seemoo.at_tracking_detection.databinding.FragmentDashboardRiskBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber @AndroidEntryPoint @@ -40,10 +52,10 @@ class DashboardRiskFragment : Fragment() { } + @SuppressLint("DiscouragedApi") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val riskCard: MaterialCardView = view.findViewById(R.id.risk_card) riskCard.setOnClickListener { val directions: NavDirections = @@ -51,6 +63,85 @@ class DashboardRiskFragment : Fragment() { findNavController().navigate(directions) } + val articlesContainer = view.findViewById(R.id.articles_container) + val progressBar = view.findViewById(R.id.loading_progress_bar) + + lifecycleScope.launch(Dispatchers.IO) { + progressBar.visibility = View.VISIBLE + + val articlesJSON = downloadJson() + Timber.d("Articles JSON: %s", articlesJSON) + + withContext(Dispatchers.Main) { + val articles = parseArticles(articlesJSON) + Timber.d("Number of Articles: %s", articles.size) + + // Create a new LinearLayout to hold the ArticleCards + val articleCardsLinearLayout = LinearLayout(context) + articleCardsLinearLayout.orientation = LinearLayout.VERTICAL + articleCardsLinearLayout.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + + for (article in articles) { + val articleCard = MaterialCardView(context) + + val layout = LayoutInflater.from(context).inflate(R.layout.include_article_card, null) + val textViewTitle = layout.findViewById(R.id.card_title) + val textViewPreviewText = layout.findViewById(R.id.card_text_preview) + val imageViewPreview = layout.findViewById(R.id.preview_image) + val materialCard = layout.findViewById(R.id.material_card) + + textViewTitle.text = article.title + if (article.previewText.isNotEmpty()){ + textViewPreviewText.text = article.previewText + } else { + textViewPreviewText.visibility = View.GONE + } + + val colorResourceId = resources.getIdentifier(article.cardColor, "color", context?.packageName) + materialCard.setBackgroundColor(colorResourceId) + + articleCard.addView(layout) + Timber.tag("CardAdded").d("Article card added: %s", article.title) + + articleCard.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = 22 + } + + if (article.preview_image.isNotEmpty()) { // TODO: Rename when in production to PreviewImage, also in JSON + val imageURL = getURL(article.preview_image) // TODO: Rename when in production to PreviewImage, also in JSON + context?.let { + Glide.with(it) + .load(imageURL) + .fitCenter() + .into(imageViewPreview) + } + } else { + imageViewPreview.visibility = View.GONE + } + + if (article.filename.isNotEmpty()) { + articleCard.setOnClickListener { + val directions: NavDirections = + DashboardRiskFragmentDirections.actionNavigationDashboardToArticleFragment( + author = article.author, + title = article.title, + filename = article.filename, + readingTime = article.readingTime + ) + findNavController().navigate(directions) + } + } + + articleCardsLinearLayout.addView(articleCard) + } + + articlesContainer.addView(articleCardsLinearLayout) + progressBar.visibility = View.GONE + } + } } override fun onStart() { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt index 1463b42..7c4e722 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/DeviceMapFragment.kt @@ -98,8 +98,5 @@ class DeviceMapFragment : Fragment() { } } } - - } - } \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/onboarding/ShareDataFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/onboarding/ShareDataFragment.kt index 34ef57c..0a9af33 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/onboarding/ShareDataFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/onboarding/ShareDataFragment.kt @@ -14,7 +14,12 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.R import de.seemoo.at_tracking_detection.databinding.FragmentShareDataBinding +import de.seemoo.at_tracking_detection.statistics.api.Api +import de.seemoo.at_tracking_detection.util.SharedPrefs import de.seemoo.at_tracking_detection.worker.BackgroundWorkScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -27,6 +32,9 @@ class ShareDataFragment : Fragment(), SlidePolicy { @Inject lateinit var backgroundWorkScheduler: BackgroundWorkScheduler + @Inject + lateinit var api: Api + private var buttonPressed: Boolean = false override fun onCreateView( diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/dialog/PlaySoundDialogFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/dialog/PlaySoundDialogFragment.kt index 54c5cc9..bce2472 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/dialog/PlaySoundDialogFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/scan/dialog/PlaySoundDialogFragment.kt @@ -20,7 +20,6 @@ import de.seemoo.at_tracking_detection.database.models.device.DeviceManager import de.seemoo.at_tracking_detection.databinding.DialogPlaySoundBinding import de.seemoo.at_tracking_detection.util.ble.BluetoothConstants import de.seemoo.at_tracking_detection.util.ble.BluetoothLeService -import kotlinx.coroutines.flow.collect import timber.log.Timber class PlaySoundDialogFragment constructor(scanResult: ScanResult) : BottomSheetDialogFragment() { @@ -83,9 +82,11 @@ class PlaySoundDialogFragment constructor(scanResult: ScanResult) : BottomSheetD } private fun dismissWithDelay() { - Handler(Looper.getMainLooper()).postDelayed({ - dismiss() - }, DIALOG_CLOSE_DELAY) + if (isAdded && !isDetached && !isRemoving) { + Handler(Looper.getMainLooper()).postDelayed({ + dismiss() + }, DIALOG_CLOSE_DELAY) + } } private val gattUpdateReceiver: BroadcastReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/DataDeletionFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/DataDeletionFragment.kt index d0f61a7..2bb608b 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/DataDeletionFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/settings/DataDeletionFragment.kt @@ -1,14 +1,31 @@ package de.seemoo.at_tracking_detection.ui.settings +import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.R +import de.seemoo.at_tracking_detection.statistics.api.Api +import de.seemoo.at_tracking_detection.util.SharedPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +@AndroidEntryPoint class DataDeletionFragment : Fragment() { + @Inject + lateinit var api: Api + + @Inject + lateinit var sharedPreferences: SharedPreferences override fun onCreateView( inflater: LayoutInflater, @@ -16,22 +33,44 @@ class DataDeletionFragment : Fragment() { savedInstanceState: Bundle? ): View? { val rootView = inflater.inflate(R.layout.fragment_data_deletion, container, false) - return rootView } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val sharedPreferences = requireActivity().getSharedPreferences("shared_preferences", 0) val deletionButton = view.findViewById