diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/ChipSpan.kt b/app/src/main/java/foundation/e/advancedprivacy/common/ChipSpan.kt new file mode 100644 index 0000000000000000000000000000000000000000..19cd47d8c5b903ef46d318a9e710969dce95b2fb --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/ChipSpan.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2025 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.advancedprivacy.common + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.text.style.ReplacementSpan +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import foundation.e.advancedprivacy.common.extensions.dpToPx +import foundation.e.advancedprivacy.common.extensions.dpToPxF + +class ChipSpan( + context: Context, + paddingHorizontal: Int, + radius: Int, + height: Int, + @ColorRes backgroundColorId: Int, + @ColorRes textColorId: Int + +) : ReplacementSpan() { + private val paddingPx = paddingHorizontal.dpToPx(context) + private val chipHeight = height.dpToPxF(context) + private val radiusPx = radius.dpToPxF(context) + + private val textColor = ContextCompat.getColor(context, textColorId) + private val backgroundPaint = Paint().apply { + color = ContextCompat.getColor(context, backgroundColorId) + style = Paint.Style.FILL + isAntiAlias = true + } + + private var size: Int = 0 + + override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + val textRect = Rect() + paint.getTextBounds(text, start, end, textRect) + size = textRect.width() + 2 * paddingPx + return size + } + + override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { + val top = y + (paint.fontMetrics.ascent + paint.fontMetrics.descent - chipHeight) / 2 + val backgroundRect = RectF( + x, + top, + x + size, + top + chipHeight + ) + canvas.drawRoundRect(backgroundRect, radiusPx, radiusPx, backgroundPaint) + + paint.color = textColor + canvas.drawText(text.slice(start until end).toString(), x + paddingPx, y.toFloat(), paint) + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt index c12a6cde691d91f020af10f2efc2e9bfcfb7d588..19ddc85260701f84f94d2eee8e4d99aed7bca217 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt @@ -60,7 +60,7 @@ class TrackersAndAppsListsUseCase( } private suspend fun get5MostTrackedAppsLastMonth(): List { - val countByApIds = statsDatabase.getCallsByAppIds(since = Period.MONTH.getPeriodStart().epochSecond) + val countByApIds = statsDatabase.getCallsByAppIds(start = Period.MONTH.getPeriodStart(), end = Instant.now()) val countByApps = mutableMapOf() countByApIds.forEach { (apId, count) -> diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/WeeklyReportUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/WeeklyReportUseCase.kt index 08eff4168d7a6b21ef5be7d7c125d2c7cce679ff..c61b7bad92090bd640ad5638e62dfa6ee1e59a64 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/WeeklyReportUseCase.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/WeeklyReportUseCase.kt @@ -27,6 +27,8 @@ import foundation.e.advancedprivacy.trackers.data.TrackersRepository import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit +import kotlin.collections.component1 +import kotlin.collections.component2 import kotlin.random.Random import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -107,9 +109,15 @@ class WeeklyReportUseCase( return candidates.map { computeScore(it, history) }.sortedBy { it.score } } - private fun addCallPerAppCandidates(candidates: MutableList, endOfWeek: Instant) { - val anyApp = appListRepository.displayableApps.value.first().id - val callsPerWeek = Random.nextInt(10000) + private suspend fun addCallPerAppCandidates(candidates: MutableList, endOfWeek: Instant) { + val startOfWeek = getStartOfWeek(endOfWeek) + val (appIdWithMostCalls, callsPerWeek) = statsDatabase.getCallsByAppIds(startOfWeek, endOfWeek).maxBy { it.value } + val app = appListRepository.getAppById(appIdWithMostCalls)?.id + + // TODO: get second one on fail ? + if (app == null) { // appId isn't on the phone anymore, skip. + return + } val hoursInWeek = 7 * 24 candidates.add( @@ -117,7 +125,7 @@ class WeeklyReportUseCase( endOfWeek, WeeklyReport.StatType.CALLS_PER_APP, WeeklyReport.LabelId.CALLS_PER_APP_1, - anyApp, + app, listOf((callsPerWeek / hoursInWeek).toString()) ) ) @@ -127,7 +135,7 @@ class WeeklyReportUseCase( endOfWeek, WeeklyReport.StatType.CALLS_PER_APP, WeeklyReport.LabelId.CALLS_PER_APP_2, - anyApp, + app, listOf((callsPerWeek / hoursInWeek).toString()) ) ) @@ -137,7 +145,7 @@ class WeeklyReportUseCase( endOfWeek, WeeklyReport.StatType.CALLS_PER_APP, WeeklyReport.LabelId.CALLS_PER_APP_3, - anyApp, + app, listOf((callsPerWeek / 7).toString()) ) ) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/CallsPerAppViewFactory.kt b/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/CallsPerAppViewFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..6f21a407869948326bf09809ffcaad22dd192f3c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/CallsPerAppViewFactory.kt @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2025 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.advancedprivacy.features.weeklyreport + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.ChipSpan +import foundation.e.advancedprivacy.databinding.WeeklyReportItemCallsPerAppBinding +import foundation.e.advancedprivacy.databinding.WeeklyReportItemCallsPerAppForSharingBinding +import foundation.e.advancedprivacy.domain.entities.weeklyreport.DisplayableReport +import foundation.e.advancedprivacy.domain.entities.weeklyreport.WeeklyReport + +class CallsPerAppViewFactory(private val context: Context) { + fun getShareTitle(): CharSequence { + return context.getString(R.string.weeklyreport_share_title_base) + } + + fun createView(report: DisplayableReport, inflater: LayoutInflater, viewGroup: ViewGroup): View? { + if (report !is DisplayableReport.ReportWithApp) { + return null + } + + val binding = WeeklyReportItemCallsPerAppBinding.inflate(inflater, viewGroup, false) + + binding.appIcon.setImageDrawable(report.app.icon) + + binding.title.text = getTitle(report) + binding.description.text = buildCallsPerAppDescription(report, false) + return binding.root + } + + fun createViewForSharing(report: DisplayableReport, inflater: LayoutInflater, viewGroup: ViewGroup): View? { + if (report !is DisplayableReport.ReportWithApp) { + return null + } + + val binding = WeeklyReportItemCallsPerAppForSharingBinding.inflate(inflater, viewGroup, false) + binding.appIcon.setImageDrawable(report.app.icon) + binding.title.text = getTitle(report) + binding.description.text = buildCallsPerAppDescription(report, true) + return binding.root + } + + fun getTitle(report: DisplayableReport): CharSequence { + return context.getString( + when (report.report.labelId) { + WeeklyReport.LabelId.CALLS_PER_APP_1 -> + R.string.weeklyreport_label_calls_per_app_1_title + + WeeklyReport.LabelId.CALLS_PER_APP_2 -> + R.string.weeklyreport_label_calls_per_app_2_title + + WeeklyReport.LabelId.CALLS_PER_APP_3 -> + R.string.weeklyreport_label_calls_per_app_3_title + + else -> + R.string.empty + } + ) + } + + fun getDescriptionForNotification(report: DisplayableReport): String { + if (report !is DisplayableReport.ReportWithApp) { + return "" + } + return buildCallsPerAppDescription(report, forSharing = false).toString() + } + + fun getIconForNotification(report: DisplayableReport): Drawable? { + return if (report is DisplayableReport.ReportWithApp) { + report.app.icon + } else { + null + } + } + + private fun buildCallsPerAppDescription(report: DisplayableReport.ReportWithApp, forSharing: Boolean = false): CharSequence { + val labelId = report.report.labelId + val desc1 = context.getString( + when (labelId) { + WeeklyReport.LabelId.CALLS_PER_APP_1 -> R.string.weeklyreport_label_calls_per_app_1_description_1 + WeeklyReport.LabelId.CALLS_PER_APP_2 -> R.string.weeklyreport_label_calls_per_app_2_description_1 + WeeklyReport.LabelId.CALLS_PER_APP_3 -> R.string.weeklyreport_label_calls_per_app_3_description_1 + else -> R.string.empty + }, + report.app.label + ) + + val counts = report.report.secondaryValues.firstOrNull() ?: "" + + val desc2 = context.getString( + when { + labelId == WeeklyReport.LabelId.CALLS_PER_APP_1 && forSharing -> + R.string.weeklyreport_label_calls_per_app_1_description_2_sharing + labelId == WeeklyReport.LabelId.CALLS_PER_APP_1 -> + R.string.weeklyreport_label_calls_per_app_1_description_2 + labelId == WeeklyReport.LabelId.CALLS_PER_APP_2 && forSharing -> + R.string.weeklyreport_label_calls_per_app_2_description_2_sharing + labelId == WeeklyReport.LabelId.CALLS_PER_APP_2 -> + R.string.weeklyreport_label_calls_per_app_2_description_2 + labelId == WeeklyReport.LabelId.CALLS_PER_APP_3 && forSharing -> + R.string.weeklyreport_label_calls_per_app_3_description_2_sharing + labelId == WeeklyReport.LabelId.CALLS_PER_APP_3 -> + R.string.weeklyreport_label_calls_per_app_3_description_2 + else -> R.string.empty + } + ) + + return buildSpannedString { + append(desc1) + append(" ") + inSpans( + ChipSpan( + context = context, + paddingHorizontal = 8, + radius = 12, + height = 16, + backgroundColorId = if (forSharing) R.color.share_blue_highlight_number else R.color.divider, + textColorId = if (forSharing) R.color.white else R.color.secondary_text + ) + ) { + append(counts) + } + append(" ") + append(desc2) + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/WeeklyReportViewFactory.kt b/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/WeeklyReportViewFactory.kt index 9e9857e1342343215d3afe5b220b720ff83ad95f..25fe0d850a77a8e5452ab2b962cbd929322e2d27 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/WeeklyReportViewFactory.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/WeeklyReportViewFactory.kt @@ -38,6 +38,7 @@ import foundation.e.advancedprivacy.domain.entities.weeklyreport.WeeklyReport class WeeklyReportViewFactory(private val context: Context) { val newTrackersViewFactory: NewTrackersViewFactory by lazy { NewTrackersViewFactory(context) } val trackerWithMostAppsViewFactory: TrackerWithMostAppsViewFactory by lazy { TrackerWithMostAppsViewFactory(context) } + val callsPerAppViewFactory: CallsPerAppViewFactory by lazy { CallsPerAppViewFactory(context) } fun getShareTitle(report: DisplayableReport): CharSequence { return when (report.report.statType) { @@ -45,6 +46,8 @@ class WeeklyReportViewFactory(private val context: Context) { newTrackersViewFactory.getShareTitle(report) WeeklyReport.StatType.TRACKER_WITH_MOST_APPS -> trackerWithMostAppsViewFactory.getShareTitle() + WeeklyReport.StatType.CALLS_PER_APP -> + callsPerAppViewFactory.getShareTitle() else -> context.getString(R.string.weeklyreport_share_title_base) } } @@ -55,6 +58,8 @@ class WeeklyReportViewFactory(private val context: Context) { newTrackersViewFactory.createView(report, inflater, viewGroup, onClick) WeeklyReport.StatType.TRACKER_WITH_MOST_APPS -> trackerWithMostAppsViewFactory.createView(report, inflater, viewGroup, onClick) + WeeklyReport.StatType.CALLS_PER_APP -> + callsPerAppViewFactory.createView(report, inflater, viewGroup) else -> null } ?: createDefaultView(report, inflater, viewGroup) } @@ -109,6 +114,9 @@ class WeeklyReportViewFactory(private val context: Context) { newTrackersViewFactory.createViewForSharing(report, inflater, viewGroup) WeeklyReport.StatType.TRACKER_WITH_MOST_APPS -> trackerWithMostAppsViewFactory.createViewForSharing(report, inflater, viewGroup) + WeeklyReport.StatType.CALLS_PER_APP -> + callsPerAppViewFactory.createViewForSharing(report, inflater, viewGroup) + else -> null } ?: createDefaultView(report, inflater, viewGroup) } @@ -128,6 +136,9 @@ class WeeklyReportViewFactory(private val context: Context) { newTrackersViewFactory.getTitle(report) WeeklyReport.StatType.TRACKER_WITH_MOST_APPS -> trackerWithMostAppsViewFactory.getTitle() + WeeklyReport.StatType.CALLS_PER_APP -> + callsPerAppViewFactory.getTitle(report) + else -> report.report.labelId.name } } @@ -138,6 +149,9 @@ class WeeklyReportViewFactory(private val context: Context) { newTrackersViewFactory.getDescriptionForNotification(report) WeeklyReport.StatType.TRACKER_WITH_MOST_APPS -> trackerWithMostAppsViewFactory.getDescriptionForNotification(report) + WeeklyReport.StatType.CALLS_PER_APP -> + callsPerAppViewFactory.getDescriptionForNotification(report) + else -> getDefaultDescription(report).toString() } } @@ -158,6 +172,8 @@ class WeeklyReportViewFactory(private val context: Context) { newTrackersViewFactory.getIconForNotification(report) WeeklyReport.StatType.TRACKER_WITH_MOST_APPS -> trackerWithMostAppsViewFactory.getIconForNotification() + WeeklyReport.StatType.CALLS_PER_APP -> + callsPerAppViewFactory.getIconForNotification(report) else -> null } } diff --git a/app/src/main/res/layout/fragment_trackers.xml b/app/src/main/res/layout/fragment_trackers.xml index 8d0cb39f383911de35d8222c5ae59a8eea0b3531..c14164e49d48aea3734ba46e2c864452989911d7 100644 --- a/app/src/main/res/layout/fragment_trackers.xml +++ b/app/src/main/res/layout/fragment_trackers.xml @@ -62,6 +62,10 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" android:text="@string/weeklyreport_section_title" + android:layout_marginTop="24dp" + android:textFontWeight="500" + android:textSize="14sp" + android:lineHeight="24dp" /> + + + > + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/weekly_report_item_calls_per_app_for_sharing.xml b/app/src/main/res/layout/weekly_report_item_calls_per_app_for_sharing.xml new file mode 100644 index 0000000000000000000000000000000000000000..8438893db8689ca6f7c99498a570800179f1e6e8 --- /dev/null +++ b/app/src/main/res/layout/weekly_report_item_calls_per_app_for_sharing.xml @@ -0,0 +1,65 @@ + + + + > + + + + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 111f3cc706c04debbaf060072224d33712cfa4ff..3ca581e1545c67a1bc24d2772ac8060766151d86 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -187,4 +187,20 @@ Wöchentlicher Bericht Eine wöchentliche Benachrichtigung, die eine Zusammenfassung der Tracker zeigt und Änderungen sowie Updates hervorhebt. + + Top-Datenschutz-Verletzer: + + %s machte + Tracking-Versuche pro Stunde in dieser Woche. + Tracking-Versuche pro Stunde in dieser Woche auf meinem Smartphone. + + Invasivste App: + %s hat Datenlecks + mal pro Stunde in dieser Woche. + mal pro Stunde in dieser Woche auf meinem Smartphone. + + Größter Datenlecker: + %s hat Daten gesendet + mal pro Tag in dieser Woche. + mal pro Tag in dieser Woche auf meinem Smartphone. \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 92c48eea74a94fb0f67ecaa39fa1b8768d3c8b40..d040f70f9072e64a9bfe7aceeccc138753146ddb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -160,8 +160,6 @@ Informe semanal: https://e.foundation/wp-content/uploads/2025/01/White_Paper_-_Privacy_-_ES.pdf - ¿Impresionado? - ¡Corre la voz! ¡Nuevo espía en la ciudad! Esta semana @@ -187,4 +185,21 @@ Informe Semanal Una notificación semanal que muestra un resumen de los rastreadores, destacando cualquier cambio y actualización en ellos. + + ¿Impresionado? + Principal infractor de privacidad: + %s ha realizado + intentos de seguimiento por hora esta semana. + intentos de seguimiento por hora esta semana en mi smartphone. + + Aplicación más invasiva: + %s ha intentado filtrar datos + veces por hora esta semana. + veces por hora esta semana en mi smartphone. + + Mayor filtrador de datos: + %s ha intentado enviar datos + veces por día esta semana. + veces por día esta semana en mi smartphone. + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6e31124436c0272d67db9432641853a498d72154..817a2c5d900ab2cab9466ea7907a60f8e3a5568e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -185,4 +185,21 @@ Rapport Hebdomadaire Une notification hebdomadaire présentant un résumé des pisteurs, mettant en évidence les changements et mises à jour. + + Principal contrevenant à la vie privée : + + %s a éffectué + tentatives de pistage par heure cette semaine. + tentatives de pistage par heure cette semaine sur mon smartphone. + + Application la plus invasive : + %s a tenté de divulguer des données + fois par heure cette semaine. + fois par heure cette semaine sur mon smartphone. + + Plus grand divulgateur de données : + %s a tenté d\'envoyer des données + fois par jour cette semaine. + fois par jour cette semaine sur mon smartphone. + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f9ef4a3096e771801acc9b65d2c51d6229cd1442..8bed1dfd74ccf1b6584ca2a33118f9d8403e8747 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -188,4 +188,21 @@ Rapporto Settimanale Una notifica settimanale che mostra un riepilogo dei tracker, evidenziando eventuali modifiche e aggiornamenti. + + Principale violatore della privacy: + + %s ha fatto + tentativi di tracciamento all\'ora questa settimana. + tentativi di tracciamento all\'ora questa settimana sul mio smartphone. + + App più invasiva: + %s ha tentato di divulgare dati + volte all\'ora questa settimana. + volte all\'ora questa settimana sul mio smartphone. + + Maggiore divulgatore di dati: + %s ha tentato di inviare dati + volte al giorno questa settimana. + volte al giorno questa settimana sul mio smartphone. + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7df349f37fa26310995c9e1684e7d0e4248985bf..989ca8fbc2d57beadaf18ab72647abb18ba2fce1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -218,6 +218,21 @@ is present in %s apps on my phone. View apps + Top Privacy Offender: + + %s made + tracking attempts per hour this week. + tracking attempts per hour this week on my smartphone. + + Most Invasive App: + %s leaked data + times per hour this week. + times per hour this week on my smartphone. + Biggest Data Leaker: + %s sent data + times per day this week. + times per day this week on my smartphone. + @string/app_name @@ -250,7 +265,6 @@ Highlight that the trackers are actually logged and blocked by Advanced Privacy Tracker control is on This could impact the functioning of some applications. - Weekly Report A weekly notification showing a summary of the trackers, highlighting any changes and updates in them. diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt index 49b9f4c1c60ebceadec7774cf54a0cc7d551d6f6..7c6222c53e512a4fe2e96e8b7e98b1987aef05e3 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt @@ -371,11 +371,11 @@ class StatsDatabase( } } - suspend fun getCallsByAppIds(since: Long): Map = withContext(Dispatchers.IO) { + suspend fun getCallsByAppIds(start: Instant, end: Instant): Map = withContext(Dispatchers.IO) { synchronized(lock) { val db = readableDatabase - val selection = "$COLUMN_NAME_TIMESTAMP >= ?" - val selectionArg = arrayOf("" + since) + val selection = "$COLUMN_NAME_TIMESTAMP >= ? AND $COLUMN_NAME_TIMESTAMP <= ?" + val selectionArg = arrayOf("" + start.epochSecond, "" + end.epochSecond) val projection = "$COLUMN_NAME_APPID, " + "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM" val cursor = db.rawQuery(