EN: Display historgram of collected IDs using hourly heat map

This commit is contained in:
Marvin W 2021-01-08 15:43:05 +01:00
parent 6e176cceed
commit 11a86d9169
No known key found for this signature in database
GPG Key ID: 072E9235DB996F2A
11 changed files with 225 additions and 134 deletions

View File

@ -14,8 +14,6 @@ dependencies {
implementation project(':play-services-nearby-core') implementation project(':play-services-nearby-core')
implementation project(':play-services-base-core-ui') implementation project(':play-services-base-core-ui')
implementation "com.diogobernardino:williamchart:3.7.1"
// AndroidX UI // AndroidX UI
implementation "androidx.multidex:multidex:$multidexVersion" implementation "androidx.multidex:multidex:$multidexVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion"

View File

@ -7,8 +7,6 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="org.microg.gms.nearby.core.ui"> package="org.microg.gms.nearby.core.ui">
<uses-sdk tools:overrideLibrary="com.db.williamchart" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

View File

@ -1,56 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.core.ui
import android.content.Context
import android.util.AttributeSet
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.db.williamchart.data.Scale
import com.db.williamchart.view.BarChartView
class BarChartPreference : Preference {
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?) : super(context)
init {
layoutResource = R.layout.preference_bar_chart
}
private lateinit var chart: BarChartView
var labelsFormatter: (Float) -> String = { it.toString() }
set(value) {
field = value
if (this::chart.isInitialized) {
chart.labelsFormatter = value
}
}
var scale: Scale? = null
set(value) {
field = value
if (value != null && this::chart.isInitialized) {
chart.scale = value
}
}
var data: LinkedHashMap<String, Float> = linkedMapOf()
set(value) {
field = value
if (this::chart.isInitialized) {
chart.animate(data)
}
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
chart = holder.itemView as? BarChartView ?: holder.findViewById(R.id.bar_chart) as BarChartView
chart.labelsFormatter = labelsFormatter
scale?.let { chart.scale = it }
chart.animate(data)
}
}

View File

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.core.ui
import android.content.Context
import android.util.AttributeSet
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import org.microg.gms.nearby.exposurenotification.ExposureScanSummary
class DotChartPreference : Preference {
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?) : super(context)
init {
layoutResource = R.layout.preference_dot_chart
}
private lateinit var chart: DotChartView
var data: Set<ExposureScanSummary> = emptySet()
set(value) {
field = value
if (this::chart.isInitialized) {
chart.data = data
}
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
chart = holder.itemView as? DotChartView ?: holder.findViewById(R.id.dot_chart) as DotChartView
chart.data = data
}
}

View File

@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: 2020, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.nearby.core.ui
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.Context
import android.content.res.TypedArray
import android.graphics.*
import android.provider.Settings
import android.text.TextUtils
import android.util.AttributeSet
import android.util.Log
import android.util.TypedValue
import android.view.View
import org.microg.gms.nearby.exposurenotification.ExposureScanSummary
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
class DotChartView : View {
@TargetApi(21)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?) : super(context)
var data: Set<ExposureScanSummary>? = null
@SuppressLint("SimpleDateFormat")
set(value) {
field = value
val displayData = hashMapOf<Int, Pair<String, MutableMap<Int, Int>>>()
val now = System.currentTimeMillis()
val min = now - 14 * 24 * 60 * 60 * 1000L
val date = Date(min)
val format = Settings.System.getString(context.contentResolver, Settings.System.DATE_FORMAT);
val dateFormat = if (TextUtils.isEmpty(format)) {
android.text.format.DateFormat.getMediumDateFormat(context)
} else {
SimpleDateFormat(format)
}
val lowest = dateFormat.parse(dateFormat.format(date))?.time ?: date.time
for (day in 0 until 15) {
date.time = now - (14 - day) * 24 * 60 * 60 * 1000L
displayData[day] = dateFormat.format(date) to hashMapOf()
}
if (value != null) {
for (summary in value) {
val off = summary.time - lowest
if (off < 0) continue
val totalHours = (off / 1000 / 60 / 60).toInt()
val day = totalHours / 24
val hour = totalHours % 24
displayData[day]?.second?.set(hour, (displayData[day]?.second?.get(hour) ?: 0) + summary.rpis)
}
}
for (hour in 0..((min-lowest)/1000/60/60).toInt()) {
displayData[0]?.second?.set(hour, displayData[0]?.second?.get(hour) ?: -1)
}
for (hour in ((min-lowest)/1000/60/60).toInt() until 24) {
displayData[14]?.second?.set(hour, displayData[14]?.second?.get(hour) ?: -1)
}
this.displayData = displayData
invalidate()
}
private var displayData: Map<Int, Pair<String, Map<Int, Int>>> = emptyMap()
private val paint = Paint()
private val tempRect = Rect()
private val tempRectF = RectF()
private fun fetchAccentColor(): Int {
val typedValue = TypedValue()
val a: TypedArray = context.obtainStyledAttributes(typedValue.data, intArrayOf(androidx.appcompat.R.attr.colorAccent))
val color = a.getColor(0, 0)
a.recycle()
return color
}
override fun onDraw(canvas: Canvas) {
if (data == null) data = emptySet()
paint.textSize = 10 * resources.displayMetrics.scaledDensity
paint.isAntiAlias = true
paint.strokeWidth = 2f
var maxTextWidth = 0
var maxTextHeight = 0
for (dateString in displayData.values.map { it.first }) {
paint.getTextBounds(dateString, 0, dateString.length, tempRect)
maxTextWidth = max(maxTextWidth, tempRect.width())
maxTextHeight = max(maxTextHeight, tempRect.height())
}
val legendLeft = maxTextWidth + 4 * resources.displayMetrics.scaledDensity
val legendBottom = maxTextHeight + 4 * resources.displayMetrics.scaledDensity
val distHeight = (height - 28 - paddingTop - paddingBottom - legendBottom).toDouble()
val distWidth = (width - 46 - paddingLeft - paddingRight - legendLeft).toDouble()
val perHeight = distHeight / 15.0
val perWidth = distWidth / 24.0
paint.textAlign = Paint.Align.RIGHT
val maxValue = displayData.values.mapNotNull { it.second.values.maxOrNull() }.maxOrNull() ?: 0
val accentColor = fetchAccentColor()
val accentRed = Color.red(accentColor)
val accentGreen = Color.green(accentColor)
val accentBlue = Color.blue(accentColor)
for (day in 0 until 15) {
val (dateString, hours) = displayData[day] ?: "" to emptyMap()
val top = day * (perHeight + 2) + paddingTop
if (day % 2 == 0) {
paint.setARGB(255, 100, 100, 100)
canvas.drawText(dateString, (paddingLeft + legendLeft - 4 * resources.displayMetrics.scaledDensity), (top + perHeight / 2.0 + maxTextHeight / 2.0).toFloat(), paint)
}
for (hour in 0 until 24) {
val value = hours[hour] ?: 0 // TODO: Actually allow null to display offline state as soon as we properly record it
val left = hour * (perWidth + 2) + paddingLeft + legendLeft
tempRectF.set(left.toFloat() + 2f, top.toFloat() + 2f, (left + perWidth).toFloat() - 2f, (top + perHeight).toFloat() - 2f)
when {
value == null -> {
paint.style = Paint.Style.FILL_AND_STROKE
paint.setARGB(30, 100, 100, 100)
canvas.drawRoundRect(tempRectF, 2f, 2f, paint)
paint.style = Paint.Style.FILL
}
maxValue == 0 -> {
paint.setARGB(50, accentRed, accentGreen, accentBlue)
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(tempRectF, 2f, 2f, paint)
paint.style = Paint.Style.FILL
}
value >= 0 -> {
val alpha = ((value.toDouble() / maxValue.toDouble()) * 255).toInt()
paint.setARGB(max(50, alpha), accentRed, accentGreen, accentBlue)
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(tempRectF, 2f, 2f, paint)
paint.style = Paint.Style.FILL
paint.setARGB(alpha, accentRed, accentGreen, accentBlue)
canvas.drawRoundRect(tempRectF, 2f, 2f, paint)
}
}
}
}
val legendTop = 15 * (perHeight + 2) + paddingTop + maxTextHeight + 4 * resources.displayMetrics.scaledDensity
paint.textAlign = Paint.Align.CENTER
paint.setARGB(255, 100, 100, 100)
for (hour in 0 until 24) {
if (hour % 3 == 0) {
val left = hour * (perWidth + 2) + paddingLeft + legendLeft + perWidth / 2.0
canvas.drawText("${hour}:00", left.toFloat(), legendTop.toFloat(), paint)
}
}
}
}

View File

@ -7,22 +7,17 @@ package org.microg.gms.nearby.core.ui
import android.annotation.TargetApi import android.annotation.TargetApi
import android.os.Bundle import android.os.Bundle
import android.text.format.DateFormat
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.db.williamchart.data.Scale
import org.microg.gms.nearby.exposurenotification.ExposureDatabase import org.microg.gms.nearby.exposurenotification.ExposureDatabase
import java.util.*
import kotlin.math.roundToInt
import kotlin.math.roundToLong
@TargetApi(21) @TargetApi(21)
class ExposureNotificationsRpisFragment : PreferenceFragmentCompat() { class ExposureNotificationsRpisFragment : PreferenceFragmentCompat() {
private lateinit var histogramCategory: PreferenceCategory private lateinit var histogramCategory: PreferenceCategory
private lateinit var histogram: BarChartPreference private lateinit var histogram: DotChartPreference
private lateinit var deleteAll: Preference private lateinit var deleteAll: Preference
private lateinit var exportDb: Preference private lateinit var exportDb: Preference
@ -63,37 +58,11 @@ class ExposureNotificationsRpisFragment : PreferenceFragmentCompat() {
fun updateChart() { fun updateChart() {
lifecycleScope.launchWhenResumed { lifecycleScope.launchWhenResumed {
val (totalRpiCount, rpiHistogram) = ExposureDatabase.with(requireContext()) { database -> val rpiHourHistogram = ExposureDatabase.with(requireContext()) { database -> database.rpiHourHistogram }
val map = linkedMapOf<String, Float>() val totalRpiCount = rpiHourHistogram.map { it.rpis }.sum()
val lowestDate = (System.currentTimeMillis() / 24 / 60 / 60 / 1000 - 13).toDouble().roundToLong() * 24 * 60 * 60 * 1000 deleteAll.isEnabled = totalRpiCount > 0
for (i in 0..13) {
val date = Calendar.getInstance().apply { this.time = Date(lowestDate + i * 24 * 60 * 60 * 1000) }.get(Calendar.DAY_OF_MONTH)
val str = when (i) {
0, 13 -> DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd"), lowestDate + i * 24 * 60 * 60 * 1000).toString()
else -> IntArray(date).joinToString("").replace("0", "\u200B")
}
map[str] = 0f
}
val refDateLow = Calendar.getInstance().apply { this.time = Date(lowestDate) }.get(Calendar.DAY_OF_MONTH)
val refDateHigh = Calendar.getInstance().apply { this.time = Date(lowestDate + 13 * 24 * 60 * 60 * 1000) }.get(Calendar.DAY_OF_MONTH)
for (entry in database.rpiHistogram) {
val time = Date(entry.key * 24 * 60 * 60 * 1000)
if (time.time < lowestDate) continue // Ignore old data
val date = Calendar.getInstance().apply { this.time = time }.get(Calendar.DAY_OF_MONTH)
val str = when (date) {
refDateLow, refDateHigh -> DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd"), entry.key * 24 * 60 * 60 * 1000).toString()
else -> IntArray(date).joinToString("").replace("0", "\u200B")
}
map[str] = entry.value.toFloat()
}
val totalRpiCount = database.totalRpiCount
totalRpiCount to map
}
deleteAll.isEnabled = totalRpiCount != 0L
histogramCategory.title = getString(R.string.prefcat_exposure_rpis_histogram_title, totalRpiCount) histogramCategory.title = getString(R.string.prefcat_exposure_rpis_histogram_title, totalRpiCount)
histogram.labelsFormatter = { it.roundToInt().toString() } histogram.data = rpiHourHistogram
histogram.scale = Scale(0f, rpiHistogram.values.max()?.coerceAtLeast(0.1f) ?: 0.1f)
histogram.data = rpiHistogram
} }
} }
} }

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2020, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<com.db.williamchart.view.BarChartView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/bar_chart"
android:layout_width="match_parent"
android:layout_height="180dp"
android:padding="16dp"
app:chart_barsColor="?attr/colorAccent"
app:chart_barsRadius="3dp"
app:chart_labelsColor="?android:attr/textColorSecondary"
app:chart_labelsSize="14sp"
app:chart_spacing="6dp" />

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2020, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<org.microg.gms.nearby.core.ui.DotChartView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/dot_chart"
android:layout_width="match_parent"
android:layout_height="230dp"
android:orientation="vertical"
android:padding="16dp" />

View File

@ -9,15 +9,16 @@
<PreferenceCategory <PreferenceCategory
android:key="prefcat_exposure_rpi_histogram" android:key="prefcat_exposure_rpi_histogram"
tools:title="@string/prefcat_exposure_rpis_histogram_title"> tools:title="@string/prefcat_exposure_rpis_histogram_title">
<org.microg.gms.nearby.core.ui.BarChartPreference <org.microg.gms.nearby.core.ui.DotChartPreference
android:key="pref_exposure_rpi_histogram" android:key="pref_exposure_rpi_histogram"
tools:layout="@layout/preference_bar_chart" /> android:selectable="false"
tools:layout="@layout/preference_dot_chart" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:layout="@layout/preference_category_no_label"> <PreferenceCategory android:layout="@layout/preference_category_no_label">
<Preference <Preference
android:key="pref_exposure_export_database" android:key="pref_exposure_export_database"
android:title="@string/pref_exposure_rpi_export_title" android:summary="@string/pref_exposure_rpi_export_summary"
android:summary="@string/pref_exposure_rpi_export_summary" /> android:title="@string/pref_exposure_rpi_export_title" />
<Preference <Preference
android:key="pref_exposure_rpi_delete_all" android:key="pref_exposure_rpi_delete_all"
android:summary="@string/pref_exposure_rpi_delete_all_summary" android:summary="@string/pref_exposure_rpi_delete_all_summary"

View File

@ -673,25 +673,14 @@ class ExposureDatabase private constructor(private val context: Context) : SQLit
} }
} }
val rpiHistogram: Map<Long, Long> val rpiHourHistogram: Set<ExposureScanSummary>
get() = readableDatabase.run { get() = readableDatabase.run {
rawQuery("SELECT round(timestamp/(24*60*60*1000)), COUNT(*) FROM $TABLE_ADVERTISEMENTS WHERE timestamp > ? GROUP BY round(timestamp/(24*60*60*1000)) ORDER BY timestamp ASC;", arrayOf((Date().time - (14 * 24 * 60 * 60 * 1000)).toString())).use { cursor -> rawQuery("SELECT round(timestamp/(60*60*1000))*60*60*1000, COUNT(*), COUNT(*) FROM $TABLE_ADVERTISEMENTS WHERE timestamp > ? GROUP BY round(timestamp/(60*60*1000)) ORDER BY timestamp ASC;", arrayOf((System.currentTimeMillis() - (14 * 24 * 60 * 60 * 1000L)).toString())).use { cursor ->
val map = linkedMapOf<Long, Long>() val set = hashSetOf<ExposureScanSummary>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
map[cursor.getLong(0)] = cursor.getLong(1) set.add(ExposureScanSummary(cursor.getLong(0), cursor.getInt(1), cursor.getInt(2)))
}
map
}
}
val totalRpiCount: Long
get() = readableDatabase.run {
rawQuery("SELECT COUNT(*) FROM $TABLE_ADVERTISEMENTS WHERE timestamp > ?;", arrayOf((Date().time - (14 * 24 * 60 * 60 * 1000)).toString())).use { cursor ->
if (cursor.moveToNext()) {
cursor.getLong(0)
} else {
0L
} }
set
} }
} }

View File

@ -9,6 +9,8 @@ import android.util.Log
import com.google.android.gms.nearby.exposurenotification.* import com.google.android.gms.nearby.exposurenotification.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
data class ExposureScanSummary(val time: Long, val rpis: Int, val records: Int)
data class PlainExposure(val rpi: ByteArray, val aem: ByteArray, val timestamp: Long, val duration: Long, val rssi: Int) data class PlainExposure(val rpi: ByteArray, val aem: ByteArray, val timestamp: Long, val duration: Long, val rssi: Int)
data class MeasuredExposure(val timestamp: Long, val duration: Long, val rssi: Int, val txPower: Int, @CalibrationConfidence val confidence: Int, val key: TemporaryExposureKey) { data class MeasuredExposure(val timestamp: Long, val duration: Long, val rssi: Int, val txPower: Int, @CalibrationConfidence val confidence: Int, val key: TemporaryExposureKey) {