Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b31bad0e authored by Guillaume Jacquart's avatar Guillaume Jacquart
Browse files

Merge branch '1724-update_home_ui' into 'main'

1724 update home ux

See merge request !157
parents 41bd9eb2 4fcaa1f6
Loading
Loading
Loading
Loading
Loading
+0 −352
Original line number Diff line number Diff line
/*
 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
 */

package foundation.e.advancedprivacy.common

import android.content.Context
import android.graphics.Canvas
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.DynamicDrawableSpan
import android.text.style.ImageSpan
import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
import com.github.mikephil.charting.charts.BarChart
import com.github.mikephil.charting.components.AxisBase
import com.github.mikephil.charting.components.MarkerView
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.components.YAxis.AxisDependency
import com.github.mikephil.charting.data.BarData
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.BarEntry
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.formatter.ValueFormatter
import com.github.mikephil.charting.highlight.Highlight
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
import com.github.mikephil.charting.renderer.XAxisRenderer
import com.github.mikephil.charting.utils.MPPointF
import foundation.e.advancedprivacy.R
import foundation.e.advancedprivacy.common.extensions.dpToPxF
import kotlin.math.floor

class GraphHolder(val barChart: BarChart, val context: Context, val isMarkerAbove: Boolean = true) {
    var data = emptyList<Pair<Int, Int>>()
        set(value) {
            field = value
            refreshDataSet()
        }
    var labels = emptyList<String>()

    var graduations: List<String?>? = null

    private var isHighlighted = false

    init {
        barChart.description = null
        barChart.setTouchEnabled(true)
        barChart.setScaleEnabled(false)

        barChart.setDrawGridBackground(false)
        barChart.setDrawBorders(false)
        barChart.axisLeft.isEnabled = false
        barChart.axisRight.isEnabled = false

        barChart.legend.isEnabled = false

        if (isMarkerAbove) prepareXAxisDashboardDay() else prepareXAxisMarkersBelow()

        val periodMarker = PeriodMarkerView(context, isMarkerAbove)
        periodMarker.chartView = barChart
        barChart.marker = periodMarker

        barChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
            override fun onValueSelected(e: Entry?, h: Highlight?) {
                h?.let {
                    val index = it.x.toInt()
                    if (index >= 0 &&
                        index < labels.size &&
                        index < this@GraphHolder.data.size
                    ) {
                        val period = labels[index]
                        val (blocked, leaked) = this@GraphHolder.data[index]
                        periodMarker.setLabel(period, blocked, leaked)
                    }
                }
                isHighlighted = true
            }

            override fun onNothingSelected() {
                isHighlighted = false
            }
        })
    }

    private fun prepareXAxisDashboardDay() {
        barChart.extraTopOffset = 44f

        barChart.offsetTopAndBottom(0)

        barChart.setXAxisRenderer(object : XAxisRenderer(
            barChart.viewPortHandler,
            barChart.xAxis,
            barChart.getTransformer(AxisDependency.LEFT)
        ) {
            override fun renderAxisLine(c: Canvas) {
                mAxisLinePaint.color = mXAxis.axisLineColor
                mAxisLinePaint.strokeWidth = mXAxis.axisLineWidth
                mAxisLinePaint.pathEffect = mXAxis.axisLineDashPathEffect

                // Top line
                c.drawLine(
                    mViewPortHandler.contentLeft(),
                    mViewPortHandler.contentTop(),
                    mViewPortHandler.contentRight(),
                    mViewPortHandler.contentTop(),
                    mAxisLinePaint
                )

                // Bottom line
                c.drawLine(
                    mViewPortHandler.contentLeft(),
                    mViewPortHandler.contentBottom() - 7.dpToPxF(context),
                    mViewPortHandler.contentRight(),
                    mViewPortHandler.contentBottom() - 7.dpToPxF(context),
                    mAxisLinePaint
                )
            }

            override fun renderGridLines(c: Canvas) {
                if (!mXAxis.isDrawGridLinesEnabled || !mXAxis.isEnabled) return
                val clipRestoreCount = c.save()
                c.clipRect(gridClippingRect)
                if (mRenderGridLinesBuffer.size != mAxis.mEntryCount * 2) {
                    mRenderGridLinesBuffer = FloatArray(mXAxis.mEntryCount * 2)
                }
                val positions = mRenderGridLinesBuffer
                run {
                    var i = 0
                    while (i < positions.size) {
                        positions[i] = mXAxis.mEntries[i / 2]
                        positions[i + 1] = mXAxis.mEntries[i / 2]
                        i += 2
                    }
                }

                mTrans.pointValuesToPixel(positions)
                setupGridPaint()
                val gridLinePath = mRenderGridLinesPath
                gridLinePath.reset()
                var i = 0
                while (i < positions.size) {
                    val bottomY = if (graduations?.getOrNull(i / 2) != null) 0 else 3
                    val x = positions[i]
                    gridLinePath.moveTo(x, mViewPortHandler.contentBottom() - 7.dpToPxF(context))
                    gridLinePath.lineTo(x, mViewPortHandler.contentBottom() - bottomY.dpToPxF(context))

                    c.drawPath(gridLinePath, mGridPaint)

                    gridLinePath.reset()

                    i += 2
                }
                c.restoreToCount(clipRestoreCount)
            }
        })

        barChart.setDrawValueAboveBar(false)
        barChart.xAxis.apply {
            isEnabled = true
            position = XAxis.XAxisPosition.BOTTOM

            setDrawGridLines(true)
            setDrawLabels(true)
            setCenterAxisLabels(false)
            setLabelCount(25, true)
            textColor = context.getColor(R.color.primary_text)
            valueFormatter = object : ValueFormatter() {
                override fun getAxisLabel(value: Float, axis: AxisBase?): String {
                    return graduations?.getOrNull(floor(value).toInt() + 1) ?: ""
                }
            }
        }
    }

    private fun prepareXAxisMarkersBelow() {
        barChart.extraBottomOffset = 44f

        barChart.offsetTopAndBottom(0)
        barChart.setDrawValueAboveBar(false)

        barChart.xAxis.apply {
            isEnabled = true
            position = XAxis.XAxisPosition.BOTH_SIDED
            setDrawGridLines(false)
            setDrawLabels(false)
        }
    }

    fun highlightIndex(index: Int) {
        if (index >= 0 && index < data.size) {
            val xPx = barChart.getTransformer(YAxis.AxisDependency.LEFT)
                .getPixelForValues(index.toFloat(), 0f)
                .x
            val highlight = Highlight(
                index.toFloat(),
                0f,
                xPx.toFloat(),
                0f,
                0,
                YAxis.AxisDependency.LEFT
            )

            barChart.highlightValue(highlight, true)
        }
    }

    private fun refreshDataSet() {
        val trackersDataSet = BarDataSet(
            data.mapIndexed { index, value ->
                BarEntry(
                    index.toFloat(),
                    floatArrayOf(value.first.toFloat(), value.second.toFloat())
                )
            },
            ""
        ).apply {
            val blockedColor = ContextCompat.getColor(context, R.color.accent)
            val leakedColor = ContextCompat.getColor(context, R.color.red_off)

            colors = listOf(
                blockedColor,
                leakedColor
            )

            setDrawValues(false)
        }

        barChart.data = BarData(trackersDataSet)
        barChart.invalidate()
    }
}

class PeriodMarkerView(context: Context, private val isMarkerAbove: Boolean = true) : MarkerView(context, R.layout.chart_tooltip) {
    enum class ArrowPosition { LEFT, CENTER, RIGHT }

    private val arrowMargins = 10.dpToPxF(context)
    private val mOffset2 = MPPointF(0f, 0f)

    private fun getArrowPosition(posX: Float): ArrowPosition {
        val halfWidth = width / 2

        return chartView?.let { chart ->
            if (posX < halfWidth) {
                ArrowPosition.LEFT
            } else if (chart.width - posX < halfWidth) {
                ArrowPosition.RIGHT
            } else {
                ArrowPosition.CENTER
            }
        } ?: ArrowPosition.CENTER
    }

    private fun showArrow(position: ArrowPosition?) {
        val ids = listOf(
            R.id.arrow_top_left,
            R.id.arrow_top_center,
            R.id.arrow_top_right,
            R.id.arrow_bottom_left,
            R.id.arrow_bottom_center,
            R.id.arrow_bottom_right
        )

        val toShow = if (isMarkerAbove) {
            when (position) {
                ArrowPosition.LEFT -> R.id.arrow_bottom_left
                ArrowPosition.CENTER -> R.id.arrow_bottom_center
                ArrowPosition.RIGHT -> R.id.arrow_bottom_right
                else -> null
            }
        } else {
            when (position) {
                ArrowPosition.LEFT -> R.id.arrow_top_left
                ArrowPosition.CENTER -> R.id.arrow_top_center
                ArrowPosition.RIGHT -> R.id.arrow_top_right
                else -> null
            }
        }

        ids.forEach { id ->
            val showIt = id == toShow
            findViewById<View>(id)?.let {
                if (it.isVisible != showIt) {
                    it.isVisible = showIt
                }
            }
        }
    }

    fun setLabel(period: String, blocked: Int, leaked: Int) {
        val span = SpannableStringBuilder(period)
        span.append(": $blocked  ")
        span.setSpan(
            ImageSpan(context, R.drawable.ic_legend_blocked, DynamicDrawableSpan.ALIGN_BASELINE),
            span.length - 1,
            span.length,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
        span.append("  $leaked  ")
        span.setSpan(
            ImageSpan(context, R.drawable.ic_legend_leaked, DynamicDrawableSpan.ALIGN_BASELINE),
            span.length - 1,
            span.length,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
        findViewById<TextView>(R.id.label).text = span.toSpannable()
    }

    override fun refreshContent(e: Entry?, highlight: Highlight?) {
        highlight?.let {
            showArrow(getArrowPosition(highlight.xPx))
        }
        super.refreshContent(e, highlight)
    }

    override fun getOffsetForDrawingAtPoint(posX: Float, posY: Float): MPPointF {
        val x = when (getArrowPosition(posX)) {
            ArrowPosition.LEFT -> -arrowMargins
            ArrowPosition.RIGHT -> -width + arrowMargins
            ArrowPosition.CENTER -> -width.toFloat() / 2
        }

        mOffset2.x = x
        mOffset2.y = if (isMarkerAbove) {
            -posY
        } else {
            -posY + (chartView?.height?.toFloat() ?: 0f) - height
        }

        return mOffset2
    }

    override fun draw(canvas: Canvas?, posX: Float, posY: Float) {
        super.draw(canvas, posX, posY)
    }
}
+6 −46
Original line number Diff line number Diff line
@@ -35,8 +35,6 @@ import java.time.temporal.ChronoUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

@@ -57,38 +55,6 @@ class TrackersStatisticsUseCase(
        .throttleFirst(windowDuration = debounce)
        .onStart { emit(Unit) }

    fun getDayStatistics(): Pair<TrackersPeriodicStatistics, Int> {
        return TrackersPeriodicStatistics(
            callsBlockedNLeaked = statisticsUseCase.getTrackersCallsOnPeriod(24, ChronoUnit.HOURS),
            periods = buildDayLabels(),
            trackersCount = statisticsUseCase.getActiveTrackersByPeriod(24, ChronoUnit.HOURS),
            graduations = buildDayGraduations()
        ) to statisticsUseCase.getContactedTrackersCount()
    }

    fun getNonBlockedTrackersCount(): Flow<Int> {
        return if (whitelistRepository.isBlockingEnabled) {
            appListsRepository.allApps().map { apps ->
                val whiteListedTrackers = mutableSetOf<Tracker>()
                val whiteListedApps = whitelistRepository.getWhiteListedApp()
                apps.forEach { app ->
                    if (app in whiteListedApps) {
                        whiteListedTrackers.addAll(statisticsUseCase.getTrackers(listOf(app)))
                    } else {
                        whiteListedTrackers.addAll(getWhiteList(app))
                    }
                }
                whiteListedTrackers.size
            }
        } else {
            flowOf(statisticsUseCase.getContactedTrackersCount())
        }
    }

    fun getMostLeakedApp(): ApplicationDescription? {
        return statisticsUseCase.getMostLeakedApp(24, ChronoUnit.HOURS)
    }

    fun getDayTrackersCalls() = statisticsUseCase.getTrackersCallsOnPeriod(24, ChronoUnit.HOURS)

    fun getDayTrackersCount() = statisticsUseCase.getActiveTrackersByPeriod(24, ChronoUnit.HOURS)
@@ -211,18 +177,12 @@ class TrackersStatisticsUseCase(
        )
    }

    suspend fun getCalls(app: ApplicationDescription): Pair<Int, Int> {
        return appListsRepository.mapReduceForHiddenApps(
            app = app,
            map = {
                statisticsUseCase.getCalls(it, 24, ChronoUnit.HOURS)
            },
            reduce = { zip ->
                zip.unzip().let { (blocked, leaked) ->
                    blocked.sum() to leaked.sum()
                }
    suspend fun getLastMonthBlockedLeaksCount(): Int {
        return statsDatabase.getBlockedLeaksCount(Period.MONTH.periodsCount, Period.MONTH.periodUnit)
    }
        )

    suspend fun getLastMonthAppsWithBLockedLeaksCount(): Int {
        return statsDatabase.getAppsWithBLockedLeaksCount(Period.MONTH.periodsCount, Period.MONTH.periodUnit)
    }

    private fun getWhiteList(app: ApplicationDescription): List<Tracker> {
+106 −175

File changed.

Preview size limit exceeded, changes collapsed.

+2 −8
Original line number Diff line number Diff line
@@ -18,7 +18,6 @@
package foundation.e.advancedprivacy.features.dashboard

import foundation.e.advancedprivacy.domain.entities.FeatureState
import foundation.e.advancedprivacy.domain.entities.LocationMode
import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState
import foundation.e.advancedprivacy.domain.entities.TrackerMode

@@ -27,11 +26,6 @@ data class DashboardState(
    val trackerMode: TrackerMode = TrackerMode.VULNERABLE,
    val isLocationHidden: Boolean = false,
    val ipScramblingMode: FeatureState = FeatureState.STOPPING,
    val locationMode: LocationMode = LocationMode.REAL_LOCATION,
    val leakedTrackersCount: Int? = null,
    val trackersCount: Int? = null,
    val allowedTrackersCount: Int? = null,
    val dayStatistics: List<Pair<Int, Int>>? = null,
    val dayLabels: List<String>? = null,
    val dayGraduations: List<String?>? = null
    val blockedCallsCount: Int = 0,
    val appsWithCallsCount: Int = 0
)
+43 −64
Original line number Diff line number Diff line
@@ -26,15 +26,12 @@ import foundation.e.advancedprivacy.R
import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase
import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -59,24 +56,20 @@ class DashboardViewModel(

    suspend fun doOnStartedState() = withContext(Dispatchers.IO) {
        merge(
            getPrivacyStateUseCase.quickPrivacyState.map {
                _state.update { s -> s.copy(quickPrivacyState = it) }
            },
            getPrivacyStateUseCase.ipScramblingMode.map {
                _state.update { s -> s.copy(ipScramblingMode = it) }
            },
            trackersStatisticsUseCase.listenUpdates().flatMapLatest {

            trackersStatisticsUseCase.listenUpdates().mapLatest {
                fetchStatistics()
            },

            getPrivacyStateUseCase.trackerMode.map {
                _state.update { s -> s.copy(trackerMode = it) }
            },
            getPrivacyStateUseCase.isLocationHidden.map {
                _state.update { s -> s.copy(isLocationHidden = it) }
            },
            getPrivacyStateUseCase.locationMode.map {
                _state.update { s -> s.copy(locationMode = it) }
            },
            getPrivacyStateUseCase.otherVpnRunning.map {
                _singleEvents.emit(
                    SingleEvent.ToastMessageSingleEvent(
@@ -88,53 +81,50 @@ class DashboardViewModel(
        ).collect {}
    }

    fun submitAction(action: Action) = viewModelScope.launch {
        when (action) {
            is Action.ToggleTrackers -> {
                getPrivacyStateUseCase.toggleTrackers(action.enabled)
                // Add delay here to prevent race condition with trackers state.
                delay(200)
                fetchStatistics().first()
    fun onClickViewTrackersStatistics() = viewModelScope.launch {
        _navigate.emit(DashboardFragmentDirections.gotoTrackersFragment())
    }
            is Action.ToggleLocation -> getPrivacyStateUseCase.toggleLocation(action.enabled)
            is Action.ToggleIpScrambling ->
                getPrivacyStateUseCase.toggleIpScrambling(action.enabled)
            is Action.ShowFakeMyLocationAction ->
                _navigate.emit(DashboardFragmentDirections.gotoFakeLocationFragment())
            is Action.ShowAppsPermissions ->
                _navigate.emit(DashboardFragmentDirections.gotoSettingsPermissionsActivity())
            is Action.ShowInternetActivityPrivacyAction ->
                _navigate.emit(DashboardFragmentDirections.gotoInternetPrivacyFragment())
            is Action.ShowTrackers ->

    fun onClickTrackersControl() = viewModelScope.launch {
        _navigate.emit(DashboardFragmentDirections.gotoTrackersFragment())
            is Action.ShowMostLeakedApp -> actionShowMostLeakedApp()
    }

    fun onClickToggleTrackersContol(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) {
        getPrivacyStateUseCase.toggleTrackers(enabled)
    }

    private suspend fun fetchStatistics(): Flow<Unit> = withContext(Dispatchers.IO) {
        trackersStatisticsUseCase.getNonBlockedTrackersCount().map { nonBlockedTrackersCount ->
            trackersStatisticsUseCase.getDayStatistics().let { (dayStatistics, trackersCount) ->
                _state.update { s ->
                    s.copy(
                        dayStatistics = dayStatistics.callsBlockedNLeaked,
                        dayLabels = dayStatistics.periods,
                        dayGraduations = dayStatistics.graduations,
                        leakedTrackersCount = dayStatistics.trackersCount,
                        trackersCount = trackersCount,
                        allowedTrackersCount = nonBlockedTrackersCount
                    )
    fun onClickFakeLocation() = viewModelScope.launch {
        _navigate.emit(DashboardFragmentDirections.gotoFakeLocationFragment())
    }

    fun onClickToggleFakeLocation(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) {
        getPrivacyStateUseCase.toggleLocation(enabled)
    }

    fun onClickIpScrambling() = viewModelScope.launch {
        _navigate.emit(DashboardFragmentDirections.gotoInternetPrivacyFragment())
    }

    fun onClickToggleIpScrambling(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) {
        getPrivacyStateUseCase.toggleIpScrambling(enabled)
    }

    fun onClickAppsPermissions() = viewModelScope.launch {
        _navigate.emit(DashboardFragmentDirections.gotoSettingsPermissionsActivity())
    }

    private suspend fun actionShowMostLeakedApp() = withContext(Dispatchers.IO) {
        _navigate.emit(
            trackersStatisticsUseCase.getMostLeakedApp()?.let {
                DashboardFragmentDirections.gotoAppTrackersFragment(appUid = it.uid)
            } ?: DashboardFragmentDirections.gotoTrackersFragment()
    private suspend fun fetchStatistics() = withContext(Dispatchers.IO) {
        val blockedCallsCount = trackersStatisticsUseCase.getLastMonthBlockedLeaksCount()

        val appsWithBlockedLeaksCount = trackersStatisticsUseCase.getLastMonthAppsWithBLockedLeaksCount()

        _state.update {
            it.copy(
                blockedCallsCount = blockedCallsCount,
                appsWithCallsCount = appsWithBlockedLeaksCount
            )
        }
    }

    sealed class SingleEvent {
        data class ToastMessageSingleEvent(
@@ -142,15 +132,4 @@ class DashboardViewModel(
            val args: List<Any> = emptyList()
        ) : SingleEvent()
    }

    sealed class Action {
        data class ToggleTrackers(val enabled: Boolean) : Action()
        data class ToggleLocation(val enabled: Boolean) : Action()
        data class ToggleIpScrambling(val enabled: Boolean) : Action()
        object ShowFakeMyLocationAction : Action()
        object ShowInternetActivityPrivacyAction : Action()
        object ShowAppsPermissions : Action()
        object ShowTrackers : Action()
        object ShowMostLeakedApp : Action()
    }
}
Loading