From ca1c825c8425b03a6613b6531b7cc7df821709db Mon Sep 17 00:00:00 2001 From: jacquarg Date: Tue, 25 Mar 2025 11:57:17 +0100 Subject: [PATCH] feat:2995: Weekly report generation and scoring framework --- app/build.gradle | 7 +- .../e/advancedprivacy/KoinModule.kt | 6 + .../weeklyreport/DisplayableReport.kt | 39 +++ .../entities/weeklyreport/WeeklyReport.kt | 49 ++++ .../weeklyreport/WeeklyReportScore.kt | 23 ++ .../domain/usecases/WeeklyReportUseCase.kt | 231 ++++++++++++++++++ .../debug/DebugWeeklyReportFragment.kt | 88 +++++++ .../weeklyreport/WeeklyReportViewFactory.kt | 44 ++++ .../layout/debug_weekly_report_fragment.xml | 64 ++++- .../res/layout/debug_weekly_report_item.xml | 67 +++++ .../res/layout/weekly_report_item_text.xml | 45 ++++ app/src/main/res/values/strings.xml | 6 + 12 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/DisplayableReport.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReport.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReportScore.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/usecases/WeeklyReportUseCase.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/WeeklyReportViewFactory.kt create mode 100644 app/src/main/res/layout/debug_weekly_report_item.xml create mode 100644 app/src/main/res/layout/weekly_report_item_text.xml diff --git a/app/build.gradle b/app/build.gradle index e798ac7b..ef9310d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,9 +17,10 @@ */ plugins { - id 'com.android.application' - id 'kotlin-android' - id 'androidx.navigation.safeargs.kotlin' + alias libs.plugins.android.application + alias libs.plugins.kotlin.android + alias libs.plugins.androidx.navigation.safeargs + alias libs.plugins.kotlin.serialization } def getSentryDsn = { -> diff --git a/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt b/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt index 7938720d..a7a3283e 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt @@ -41,6 +41,7 @@ import foundation.e.advancedprivacy.domain.usecases.TrackersAndAppsListsUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersScreenUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStateUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.advancedprivacy.domain.usecases.WeeklyReportUseCase import foundation.e.advancedprivacy.dummy.CityDataSource import foundation.e.advancedprivacy.externalinterfaces.permissions.IPermissionsPrivacyModule import foundation.e.advancedprivacy.features.dashboard.DashboardViewModel @@ -51,6 +52,7 @@ import foundation.e.advancedprivacy.features.trackers.TrackersPeriodViewModel import foundation.e.advancedprivacy.features.trackers.TrackersViewModel import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersViewModel import foundation.e.advancedprivacy.features.trackers.trackerdetails.TrackerDetailsViewModel +import foundation.e.advancedprivacy.features.weeklyreport.WeeklyReportViewFactory import foundation.e.advancedprivacy.ipscrambler.ipScramblerModule import foundation.e.advancedprivacy.permissions.externalinterfaces.PermissionsPrivacyModuleImpl import foundation.e.advancedprivacy.trackers.data.TrackersRepository @@ -161,6 +163,8 @@ val appModule = module { TrackersScreenUseCase(localStateRepository = get()) } + singleOf(::WeeklyReportUseCase) + single { PermissionsPrivacyModuleImpl(context = androidContext()) } @@ -215,4 +219,6 @@ val appModule = module { viewModelOf(::FakeLocationViewModel) viewModelOf(::InternetPrivacyViewModel) viewModelOf(::DashboardViewModel) + + single { WeeklyReportViewFactory() } } diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/DisplayableReport.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/DisplayableReport.kt new file mode 100644 index 00000000..c430ccf8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/DisplayableReport.kt @@ -0,0 +1,39 @@ +/* + * 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.domain.entities.weeklyreport + +import foundation.e.advancedprivacy.domain.entities.DisplayableApp +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker + +sealed class DisplayableReport { + abstract val report: WeeklyReport + + data class ReportText( + override val report: WeeklyReport + ) : DisplayableReport() + + data class ReportWithApp( + override val report: WeeklyReport, + val app: DisplayableApp + ) : DisplayableReport() + + data class ReportWithTracker( + override val report: WeeklyReport, + val tracker: Tracker + ) : DisplayableReport() +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReport.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReport.kt new file mode 100644 index 00000000..b3998ca9 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReport.kt @@ -0,0 +1,49 @@ +/* + * 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.domain.entities.weeklyreport + +import kotlinx.serialization.Serializable + +@Serializable +data class WeeklyReport( + val statType: StatType, + val labelId: LabelId, + val primaryValue: String, + val secondaryValues: List +) { + @Serializable + enum class StatType { + CALLS_PER_APP, + NEW_TRACKER, + CALLS_AND_LEAKS, + TRACKER_WITH_MOST_APPS + } + + @Serializable + enum class LabelId { + CALLS_PER_APP_1, + CALLS_PER_APP_2, + CALLS_PER_APP_3, + NEW_TRACKER_1, + NEW_TRACKER_2, + NEW_TRACKER_3, + CALLS_AND_LEAKS_1, + CALLS_AND_LEAKS_2, + TRACKER_WITH_MOST_APPS_1 + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReportScore.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReportScore.kt new file mode 100644 index 00000000..9b61988f --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/weeklyreport/WeeklyReportScore.kt @@ -0,0 +1,23 @@ +/* + * 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.domain.entities.weeklyreport + +data class WeeklyReportScore( + val weeklyReport: WeeklyReport, + val score: Long +) 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 new file mode 100644 index 00000000..2f2a79c4 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/WeeklyReportUseCase.kt @@ -0,0 +1,231 @@ +/* + * 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.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.AppListRepository +import foundation.e.advancedprivacy.domain.entities.weeklyreport.DisplayableReport +import foundation.e.advancedprivacy.domain.entities.weeklyreport.WeeklyReport +import foundation.e.advancedprivacy.domain.entities.weeklyreport.WeeklyReportScore +import foundation.e.advancedprivacy.trackers.data.TrackersRepository +import kotlin.random.Random +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class WeeklyReportUseCase( + private val trackersRepository: TrackersRepository, + private val appListRepository: AppListRepository +) { + suspend fun debugGenerateReportsSinceWeeksAgo(weeksAgo: Int): List>> = + withContext(Dispatchers.IO) { + val history = mutableListOf() + + val result = mutableListOf>>() + (weeksAgo downTo 0).map { + val candidates = buildCandidates(history) + val weeklyReport = candidates.first().weeklyReport + history.add(weeklyReport) + + result.add(weeklyReport.toDisplayableReport() to candidates) + } + + result.reversed() + } + + private fun buildCandidates(history: List): List { + val candidates = mutableListOf() + addCallPerAppCandidates(candidates) + addNewTrackerCandidates(candidates) + addCallAndLeaksCandidates(candidates) + addTrackerWithMostAppsCandidates(candidates) + + return candidates.map { computeScore(it, history) }.sortedBy { it.score } + } + + private fun addCallPerAppCandidates(candidates: MutableList) { + val anyApp = appListRepository.displayableApps.value.first().id + val callsPerWeek = Random.nextInt(10000) + + val hoursInWeek = 7 * 24 + candidates.add( + WeeklyReport( + WeeklyReport.StatType.CALLS_PER_APP, + WeeklyReport.LabelId.CALLS_PER_APP_1, + anyApp, + listOf((callsPerWeek / hoursInWeek).toString()) + ) + ) + + candidates.add( + WeeklyReport( + WeeklyReport.StatType.CALLS_PER_APP, + WeeklyReport.LabelId.CALLS_PER_APP_2, + anyApp, + listOf((callsPerWeek / hoursInWeek).toString()) + ) + ) + + candidates.add( + WeeklyReport( + WeeklyReport.StatType.CALLS_PER_APP, + WeeklyReport.LabelId.CALLS_PER_APP_3, + anyApp, + listOf((callsPerWeek / 7).toString()) + ) + ) + } + + private fun addNewTrackerCandidates(candidates: MutableList) { + val anyApp2 = appListRepository.displayableApps.value.first().id + candidates.add( + WeeklyReport( + WeeklyReport.StatType.NEW_TRACKER, + WeeklyReport.LabelId.NEW_TRACKER_1, + anyApp2, + emptyList() + ) + ) + + candidates.add( + WeeklyReport( + WeeklyReport.StatType.NEW_TRACKER, + WeeklyReport.LabelId.NEW_TRACKER_2, + "", + listOf(Random.nextInt(4).toString()) + ) + ) + + candidates.add( + WeeklyReport( + WeeklyReport.StatType.NEW_TRACKER, + WeeklyReport.LabelId.NEW_TRACKER_3, + "", + listOf(Random.nextInt(4).toString()) + ) + ) + } + + private fun addCallAndLeaksCandidates(candidates: MutableList) { + val blockedRate = Random.nextInt(100).toString() + val leaks = Random.nextInt(1000).toString() + + candidates.add( + WeeklyReport( + WeeklyReport.StatType.CALLS_AND_LEAKS, + WeeklyReport.LabelId.CALLS_AND_LEAKS_1, + blockedRate, + listOf(leaks) + ) + ) + candidates.add( + WeeklyReport( + WeeklyReport.StatType.CALLS_AND_LEAKS, + WeeklyReport.LabelId.CALLS_AND_LEAKS_2, + blockedRate, + listOf(leaks) + ) + ) + } + + private fun addTrackerWithMostAppsCandidates(candidates: MutableList) { + candidates.add( + WeeklyReport( + WeeklyReport.StatType.TRACKER_WITH_MOST_APPS, + WeeklyReport.LabelId.TRACKER_WITH_MOST_APPS_1, + "wtm_yahoo", + listOf(Random.nextInt(20).toString()) + ) + ) + } + + private fun computeScore(weeklyReport: WeeklyReport, history: List): WeeklyReportScore { + // Smaller score is the best to display. + // Score is the rank in history, categorized by most visible parameters: + // * report with just similar stat type are changing enough (thank to various labels), so their impact on score is smaller, + // * report with same label but different values comes after, + // * report with same stat type and value start being repetitive + // * same label and value is just a repetition, so impact on the score is the bigger. + + // 100 means we have space for 99 rank values, almost 2 years of weeks history + val baseShift = 100L + val statShift = baseShift // 10^2 + val statLabelShift = baseShift * statShift // 10^4 + val statValueShift = baseShift * statLabelShift // 10^6 + val statLabelValueShift = baseShift * statValueShift // 10^8 + val statLabelAllValueShift = baseShift * statLabelValueShift // 10^10 + + var score = Random.nextLong(99) + + val updateScore = { shift: Long, comparator: (WeeklyReport) -> Boolean -> + val index = history.indexOfLast(comparator) + if (index != -1) { + score += (index + 1) * shift + } + } + + updateScore(statShift) { + it.statType == weeklyReport.statType + } + + updateScore(statLabelShift) { + it.statType == weeklyReport.statType && it.labelId == weeklyReport.labelId + } + + updateScore(statValueShift) { + it.statType == weeklyReport.statType && it.primaryValue == weeklyReport.primaryValue + } + + updateScore(statLabelValueShift) { + it.statType == weeklyReport.statType && + it.labelId == weeklyReport.labelId && + it.primaryValue == weeklyReport.primaryValue + } + + updateScore(statLabelAllValueShift) { + it.statType == weeklyReport.statType && + it.labelId == weeklyReport.labelId && + it.primaryValue == weeklyReport.primaryValue && + it.secondaryValues == weeklyReport.secondaryValues + } + + return WeeklyReportScore( + weeklyReport = weeklyReport, + score = score + ) + } + + private fun WeeklyReport.toDisplayableReport(): DisplayableReport? { + val weeklyReport = this + return when (weeklyReport.labelId) { + WeeklyReport.LabelId.CALLS_PER_APP_1, + WeeklyReport.LabelId.CALLS_PER_APP_2, + WeeklyReport.LabelId.CALLS_PER_APP_3, + WeeklyReport.LabelId.NEW_TRACKER_1 -> + appListRepository.getAppById(weeklyReport.primaryValue)?.let { app -> + DisplayableReport.ReportWithApp(weeklyReport, app) + } + WeeklyReport.LabelId.NEW_TRACKER_2, + WeeklyReport.LabelId.NEW_TRACKER_3, + WeeklyReport.LabelId.CALLS_AND_LEAKS_1, + WeeklyReport.LabelId.CALLS_AND_LEAKS_2 -> DisplayableReport.ReportText(weeklyReport) + + WeeklyReport.LabelId.TRACKER_WITH_MOST_APPS_1 -> trackersRepository.getTracker(weeklyReport.primaryValue)?.let { tracker -> + DisplayableReport.ReportWithTracker(weeklyReport, tracker) + } + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/debug/DebugWeeklyReportFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/debug/DebugWeeklyReportFragment.kt index 3ca369c5..1d0178f3 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/features/debug/DebugWeeklyReportFragment.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/features/debug/DebugWeeklyReportFragment.kt @@ -18,16 +18,104 @@ package foundation.e.advancedprivacy.features.debug import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.BindingListAdapter +import foundation.e.advancedprivacy.common.BindingViewHolder import foundation.e.advancedprivacy.databinding.DebugWeeklyReportFragmentBinding +import foundation.e.advancedprivacy.databinding.DebugWeeklyReportItemBinding +import foundation.e.advancedprivacy.domain.entities.weeklyreport.DisplayableReport +import foundation.e.advancedprivacy.domain.entities.weeklyreport.WeeklyReportScore +import foundation.e.advancedprivacy.domain.usecases.WeeklyReportUseCase +import foundation.e.advancedprivacy.features.weeklyreport.WeeklyReportViewFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject class DebugWeeklyReportFragment : Fragment(R.layout.debug_weekly_report_fragment) { + private val weeklyReportUseCase: WeeklyReportUseCase by inject() private lateinit var binding: DebugWeeklyReportFragmentBinding + private val reportsFactory: WeeklyReportViewFactory by inject() + private val weeklyReportsAdapter = object : BindingListAdapter< + DebugWeeklyReportItemBinding, + Pair> + >() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder { + val binding = DebugWeeklyReportItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + + binding.showDetails.setOnClickListener { + binding.details.isVisible = !binding.details.isVisible + } + + return BindingViewHolder(binding) + } + + override fun onBindViewHolder(holder: BindingViewHolder, position: Int) { + val report = dataSet[position] + report.first?.let { displayableReport -> + reportsFactory.createView( + displayableReport, + LayoutInflater.from(holder.binding.container.context), + holder.binding.container + ) + }?.let { + Log.d("DebugReport", "onBindViewHolder has a computed view") + holder.binding.container.addView(it) + } + + holder.binding.details.text = report.second.joinToString("\n") { weeklyReportScore -> + "${weeklyReportScore.score} - ${ + with(weeklyReportScore.weeklyReport) { + "$labelId ($statType) $primaryValue, $secondaryValues" + } + }" + } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding = DebugWeeklyReportFragmentBinding.bind(view) + + setupRecyclerView(binding.reports) + + binding.reports.adapter = weeklyReportsAdapter + + binding.debugUpdateReport.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + // TODO + } + } + + binding.debugGenerateOldReport.setOnClickListener { + lifecycleScope.launch { + val count = binding.debugWeeksAgo.text.toString().toInt() + weeklyReportsAdapter.dataSet = weeklyReportUseCase.debugGenerateReportsSinceWeeksAgo(count).let { + Log.d("DebugReport", "${it.joinToString("\n")}") + it + } + } + } + } + + private fun setupRecyclerView(recyclerView: RecyclerView) { + recyclerView.apply { + layoutManager = LinearLayoutManager(context) + setHasFixedSize(true) + isNestedScrollingEnabled = false + } } } 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 new file mode 100644 index 00000000..6ef8ffe7 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/weeklyreport/WeeklyReportViewFactory.kt @@ -0,0 +1,44 @@ +/* + * 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.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import foundation.e.advancedprivacy.databinding.WeeklyReportItemTextBinding +import foundation.e.advancedprivacy.domain.entities.weeklyreport.DisplayableReport + +class WeeklyReportViewFactory() { + fun createView(report: DisplayableReport, inflater: LayoutInflater, viewGroup: ViewGroup): View? { + val binding = WeeklyReportItemTextBinding.inflate(inflater, viewGroup, false) + + binding.title.text = report.report.labelId.name + binding.description.text = getDescription(report) + return binding.root + } + + private fun getDescription(report: DisplayableReport): CharSequence { + val primaryValue = when (report) { + is DisplayableReport.ReportWithApp -> report.app.label + is DisplayableReport.ReportText -> report.report.primaryValue + is DisplayableReport.ReportWithTracker -> report.tracker.label + } + + return "${report.report.statType.name} $primaryValue, ${report.report.secondaryValues.joinToString(", ")}" + } +} diff --git a/app/src/main/res/layout/debug_weekly_report_fragment.xml b/app/src/main/res/layout/debug_weekly_report_fragment.xml index b5cd3598..1095808b 100644 --- a/app/src/main/res/layout/debug_weekly_report_fragment.xml +++ b/app/src/main/res/layout/debug_weekly_report_fragment.xml @@ -14,15 +14,57 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - - - - + android:background="@color/background" + > + + + + + + + + + + diff --git a/app/src/main/res/layout/debug_weekly_report_item.xml b/app/src/main/res/layout/debug_weekly_report_item.xml new file mode 100644 index 00000000..18ee61b2 --- /dev/null +++ b/app/src/main/res/layout/debug_weekly_report_item.xml @@ -0,0 +1,67 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/weekly_report_item_text.xml b/app/src/main/res/layout/weekly_report_item_text.xml new file mode 100644 index 00000000..01711f90 --- /dev/null +++ b/app/src/main/res/layout/weekly_report_item_text.xml @@ -0,0 +1,45 @@ + + + + > + + + \ 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 14898632..9bb5ed80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -222,4 +222,10 @@ AdvancedPrivacy debug tools Weekly Report debug tools + Update report + Generate report of week ago: + Share + Details + Notification + -- GitLab