diff --git a/.gitignore b/.gitignore index dddde1381..7ab6a3c49 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ release/ dev_release/ debug/ second/ +/.kotlin/ diff --git a/build.gradle b/build.gradle index 4347938ed..0fa0fb0a2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,16 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.6.1' apply false - id 'com.android.library' version '8.6.1' apply false - id 'org.jetbrains.kotlin.android' version '1.9.23' apply false + id 'com.android.application' version '8.8.0' apply false + id 'com.android.library' version '8.8.0' apply false + id 'org.jetbrains.kotlin.android' version '2.1.0' apply false + id 'com.google.devtools.ksp' version "2.1.0-1.0.29" apply false } Properties properties = new Properties() properties.load(project.rootProject.file('local.properties').newDataInputStream()) -project.ext.set("versionCode", 98) -project.ext.set("versionName", "1.3") +project.ext.set("versionCode", 99) +project.ext.set("versionName", "1.3.1") project.ext.set("compileSdk", 34) project.ext.set("targetSdk", 34) project.ext.set("minSdk", 26) diff --git a/common/build.gradle b/common/build.gradle index b6a16a7c7..9f8844349 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'com.google.devtools.ksp' } android { @@ -48,10 +49,18 @@ dependencies { implementation 'com.google.android.gms:play-services-wearable:19.0.0' implementation 'androidx.work:work-runtime:2.9.1' implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.13.12' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation 'org.mockito:mockito-core:4.11.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0' + implementation 'com.google.code.gson:gson:2.10.1' + + //Room + def roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") } \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/Constants.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/Constants.kt index 17378091b..9aadd511b 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/Constants.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/Constants.kt @@ -11,6 +11,7 @@ object Constants { const val REQUEST_DATA_MESSAGE_PATH = "/request_data_intent" const val REQUEST_LOGCAT_MESSAGE_PATH = "/request_logcat_intent" const val LOGCAT_CHANNEL_PATH = "/logcat_intent" + const val DB_SYNC_CHANNEL_PATH = "/db_sync_intent" const val COMMAND_PATH = "/command_intent" const val GLUCODATA_BROADCAST_ACTION = "glucodata.Minute" const val SETTINGS_BUNDLE = "settings_bundle" @@ -28,6 +29,7 @@ object Constants { const val ACTION_FLOATING_WIDGET_TOGGLE = ACTION_PREFIX + "floating_widget_toggle" const val ACTION_DUMMY_VALUE = ACTION_PREFIX + "dummy_value" const val ACTION_SPEAK = ACTION_PREFIX + "speak" + const val ACTION_GRAPH = ACTION_PREFIX + "show_graph" const val IS_SECOND = BuildConfig.BUILD_TYPE == "second" @@ -284,4 +286,15 @@ object Constants { const val AA_MEDIA_PLAYER_DURATION = "aa_media_player_duration" const val PATIENT_NAME = "patient_name" + + // database + const val DB_MAX_DATA_WEAR_TIME_MS = (24*60*60*1000) // 24h + const val DB_MAX_DATA_TIME_MS = (7*24*60*60*1000) // 7 days + const val SHARED_PREF_RESET_DATABASE = "reset_db" + + // graph + const val GRAPH_ID = "graph_id" + const val SHARED_PREF_GRAPH_DURATION_WEAR_COMPLICATION = "graph_duration_wear_complication" + const val SHARED_PREF_GRAPH_DURATION_PHONE_MAIN = "graph_duration_phone_main" + } diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/GlucoDataService.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/GlucoDataService.kt index 8fa2d4d68..ae1f21f36 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/GlucoDataService.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/GlucoDataService.kt @@ -180,11 +180,7 @@ abstract class GlucoDataService(source: AppSource) : WearableListenerService(), fun registerReceiver(context: Context, receiver: ReceiverBase, filter: IntentFilter): Boolean { Log.i(LOG_ID, "Register receiver ${receiver.getName()} for $receiver on $context") try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(receiver, filter, RECEIVER_EXPORTED or RECEIVER_VISIBLE_TO_INSTANT_APPS) - } else { - context.registerReceiver(receiver, filter) - } + PackageUtils.registerReceiver(context, receiver, filter) registeredReceivers.add(receiver.getName()) return true } catch (exc: Exception) { diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/ReceiveData.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/ReceiveData.kt index 939797194..a9f8ea49a 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/ReceiveData.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/ReceiveData.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import android.graphics.Color import android.os.Bundle import android.util.Log +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notification.AlarmHandler import de.michelinside.glucodatahandler.common.notification.AlarmNotificationBase import de.michelinside.glucodatahandler.common.notification.AlarmType @@ -64,6 +65,7 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { var cob: Float = Float.NaN var iobCobTime: Long = 0 private var lowValue: Float = 70F + val lowRaw: Float get() = lowValue val low: Float get() { if(isMmol && lowValue > 0F) // mmol/l { @@ -72,6 +74,7 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { return lowValue } private var highValue: Float = 240F + val highRaw: Float get() = highValue val high: Float get() { if(isMmol && highValue > 0F) // mmol/l { @@ -80,6 +83,7 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { return highValue } private var targetMinValue = 90F + val targetMinRaw: Float get() = targetMinValue val targetMin: Float get() { if(isMmol) // mmol/l { @@ -88,6 +92,7 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { return targetMinValue } private var targetMaxValue = 165F + val targetMaxRaw: Float get() = targetMaxValue val targetMax: Float get() { if(isMmol) // mmol/l { @@ -139,6 +144,7 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { initialized = true unitMgDl = context.resources.getString(R.string.unit_mgdl) unitMmol = context.resources.getString(R.string.unit_mmol) + dbAccess.init(context) AlarmHandler.initData(context) readTargets(context) loadExtras(context) @@ -193,6 +199,13 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { return rawValue.toString() } + fun getDbValue(): Int { + if(isMmol) { + return GlucoDataUtils.mmolToMg(glucose).toInt() + } + return rawValue + } + fun getDeltaAsString(): String { if(isObsoleteShort() || deltaValue.isNaN()) return "--" @@ -319,6 +332,19 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { return AlarmType.OK } + fun getValueColor(rawValue: Int): Int { + val customValue = if(isMmol) GlucoDataUtils.mgToMmol(rawValue.toFloat()) else rawValue.toFloat() + if(high>0F && customValue >= high) + return getAlarmTypeColor(AlarmType.VERY_HIGH) + if(low>0F && customValue <= low) + return getAlarmTypeColor(AlarmType.VERY_LOW) + if(targetMin>0F && customValue < targetMin) + return getAlarmTypeColor(AlarmType.LOW) + if(targetMax>0F && customValue > targetMax) + return getAlarmTypeColor(AlarmType.HIGH) + return getAlarmTypeColor(AlarmType.OK) + } + private fun calculateAlarm(): Int { alarm = 0 // reset to calculate again val curAlarmType = getAlarmType() @@ -541,6 +567,8 @@ object ReceiveData: SharedPreferences.OnSharedPreferenceChangeListener { } } + dbAccess.addGlucoseValue(time, getDbValue()) + val notifySource = if(interApp) NotifySource.MESSAGECLIENT else NotifySource.BROADCAST InternalNotifier.notify(context, notifySource, createExtras()) // re-create extras to have all changed value inside... diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/WearPhoneConnection.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/WearPhoneConnection.kt index d8b06099c..6175e7ced 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/WearPhoneConnection.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/WearPhoneConnection.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.util.Log import com.google.android.gms.tasks.Tasks import com.google.android.gms.wearable.* +import de.michelinside.glucodatahandler.common.database.dbSync import de.michelinside.glucodatahandler.common.notification.AlarmHandler import de.michelinside.glucodatahandler.common.notification.AlarmNotificationBase import de.michelinside.glucodatahandler.common.notification.AlarmType @@ -29,7 +30,8 @@ enum class Command { DISABLE_INACTIVE_TIME, PAUSE_NODE, RESUME_NODE, - FORCE_UPDATE + FORCE_UPDATE, + DB_SYNC } class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityClient.OnCapabilityChangedListener, NotifierInterface { @@ -79,7 +81,7 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC } else if (addMissing) { batterLevels.add(-1) - }) + } else {}) } return batterLevels } @@ -102,7 +104,7 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC } else if (addMissing) { connectionStates[getDisplayName(node.value)] = context.getString(R.string.state_await_data) - }) + } else {}) } return connectionStates @@ -384,9 +386,10 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC LOG_ID, dataSource.toString() + " data send to node " + node.toString() ) - noDataSend.remove(node.id) - if(notConnectedNodes.isEmpty()) - removeTimer() + if(noDataSend.contains(node.id)) { + noDataSend.remove(node.id) + checkNodeConnect(node.id) + } } addOnFailureListener { error -> if (retryCount < 2) { @@ -412,6 +415,20 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC } } + private fun checkNodeConnect(nodeId: String) { + Log.d(LOG_ID, "check node connect $nodeId") + if(!noDataReceived.contains(nodeId) && !noDataSend.contains(nodeId)) { + Log.i(LOG_ID, "Node with id " + nodeId + " connected!") + if(notConnectedNodes.isEmpty()) + removeTimer() + dbSync.requestDbSync(context) + } else if(noDataReceived.contains(nodeId)) { + Log.i(LOG_ID, "Node with id " + nodeId + " still waiting for receiving data!") + } else if(noDataSend.contains(nodeId)) { + Log.i(LOG_ID, "Node with id " + nodeId + " still sending data!") + } + } + fun sendCommand(command: Command, extras: Bundle?) { // Send command to all nodes in parallel Log.d(LOG_ID, "sendCommand called for $command with extras: ${Utils.dumpBundle(extras)}") @@ -444,9 +461,10 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC try { Log.i(LOG_ID, "onMessageReceived from " + p0.sourceNodeId + " with path " + p0.path) checkConnectedNode(p0.sourceNodeId) - noDataReceived.remove(p0.sourceNodeId) - if (notConnectedNodes.isEmpty()) - removeTimer() + if(noDataReceived.contains(p0.sourceNodeId)) { + noDataReceived.remove(p0.sourceNodeId) + checkNodeConnect(p0.sourceNodeId) + } val extras = Utils.bytesToBundle(p0.data) //Log.v(LOG_ID, "Received extras for path ${p0.path}: ${Utils.dumpBundle(extras)}") if(extras!= null) { @@ -562,6 +580,10 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC if(p0.path == Constants.REQUEST_DATA_MESSAGE_PATH || forceSend) { Log.d(LOG_ID, "Data request received from " + p0.sourceNodeId) + if (p0.path == Constants.REQUEST_DATA_MESSAGE_PATH) { + // new data request -> new connection on other side -> reset connection + noDataSend.add(p0.sourceNodeId) // add to trigger db sync after connection established + } var bundle = ReceiveData.createExtras() var source = NotifySource.BROADCAST if( bundle == null && BatteryReceiver.batteryPercentage >= 0) { @@ -583,7 +605,7 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC if(p0.path == Constants.REQUEST_LOGCAT_MESSAGE_PATH) { sendLogcat(p0.sourceNodeId) } - if (noDataSend.contains(p0.sourceNodeId)) { + if (p0.path != Constants.REQUEST_DATA_MESSAGE_PATH && noDataSend.contains(p0.sourceNodeId)) { sendDataRequest() } } catch (exc: Exception) { @@ -621,6 +643,7 @@ class WearPhoneConnection : MessageClient.OnMessageReceivedListener, CapabilityC InternalNotifier.notify(context, NotifySource.MESSAGECLIENT, bundle) } } + Command.DB_SYNC -> dbSync.sendData(context, nodeId) } } catch (exc: Exception) { diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmap.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmap.kt new file mode 100644 index 000000000..ea10d5492 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmap.kt @@ -0,0 +1,35 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import android.view.View + +class ChartBitmap(val context: Context, durationPref: String = "", width: Int = 1000, height: Int = 0, forComplication: Boolean = false, showAxis: Boolean = false) { + + private val LOG_ID = "GDH.Chart.Bitmap" + + private var chartViewer: ChartBitmapCreator + private var chart: GlucoseChart = GlucoseChart(context) + init { + val viewHeight = if(height > 0) height else width/3 + Log.v(LOG_ID, "init - width: $width - durationPref: $durationPref") + chart.measure (View.MeasureSpec.makeMeasureSpec (width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec (viewHeight, View.MeasureSpec.EXACTLY)) + chart.layout (0, 0, chart.getMeasuredWidth(), chart.getMeasuredHeight()) + + chartViewer = ChartBitmapCreator(chart, context, durationPref, forComplication, showAxis) + chartViewer.create(true) + } + + fun close() { + Log.v(LOG_ID, "close") + chartViewer.close() + } + + fun getBitmap(): Bitmap? { + return chartViewer.getBitmap() + } + + val chartId: Int get() = chart.id + +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmapCreator.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmapCreator.kt new file mode 100644 index 000000000..ba2ef6922 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmapCreator.kt @@ -0,0 +1,81 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.util.Log +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import de.michelinside.glucodatahandler.common.Constants +import de.michelinside.glucodatahandler.common.database.dbAccess +import de.michelinside.glucodatahandler.common.notifier.InternalNotifier +import de.michelinside.glucodatahandler.common.notifier.NotifySource + +class ChartBitmapCreator(chart: GlucoseChart, context: Context, durationPref: String = "", private val forComplication: Boolean = false, private val showAxis: Boolean = false): ChartCreator(chart, context, durationPref) { + private val LOG_ID = "GDH.Chart.BitmapCreator" + private var bitmap: Bitmap? = null + override val resetChart = true + override val circleRadius = 3F + override var durationHours = 2 + override val touchEnabled = false + + override fun initXaxis() { + Log.v(LOG_ID, "initXaxis") + chart.xAxis.isEnabled = showAxis + if(chart.xAxis.isEnabled) + super.initXaxis() + } + + override fun initYaxis() { + Log.v(LOG_ID, "initYaxis") + if(showAxis) { + super.initYaxis() + } else { + chart.axisRight.setDrawAxisLine(false) + chart.axisRight.setDrawLabels(false) + chart.axisRight.setDrawZeroLine(false) + chart.axisRight.setDrawGridLines(false) + chart.axisLeft.isEnabled = false + } + } + + override fun initChart() { + super.initChart() + chart.isDrawingCacheEnabled = false + } + + override fun getMaxRange(): Long { + return getDefaultRange() + } + + override fun getDefaultMaxValue(): Float { + if(forComplication) + return maxOf(super.getDefaultMaxValue(), 310F) + return super.getDefaultMaxValue() + } + + override fun updateChart(dataSet: LineDataSet?) { + if(dataSet != null) { + Log.v(LOG_ID, "Update chart for ${dataSet.values.size} entries and ${dataSet.circleColors.size} colors") + if(dataSet.values.isNotEmpty()) + chart.data = LineData(dataSet) + else + chart.data = LineData() + } + addEmptyTimeData() + chart.notifyDataSetChanged() + bitmap = null // reset + chart.postInvalidate() + InternalNotifier.notify(context, NotifySource.GRAPH_CHANGED, Bundle().apply { putInt(Constants.GRAPH_ID, chart.id) }) + } + + override fun updateTimeElapsed() { + update(dbAccess.getGlucoseValues(getMinTime())) + } + + fun getBitmap(): Bitmap? { + if(bitmap == null) + bitmap = createBitmap() + return bitmap + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmapView.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmapView.kt new file mode 100644 index 000000000..d89012905 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartBitmapView.kt @@ -0,0 +1,49 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.widget.ImageView +import de.michelinside.glucodatahandler.common.Constants +import de.michelinside.glucodatahandler.common.notifier.InternalNotifier +import de.michelinside.glucodatahandler.common.notifier.NotifierInterface +import de.michelinside.glucodatahandler.common.notifier.NotifySource +import de.michelinside.glucodatahandler.common.utils.Utils +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class ChartBitmapView(val imageView: ImageView, val context: Context, durationPref: String = "", showAxis: Boolean = false): NotifierInterface { + + private val LOG_ID = "GDH.Chart.BitmapView" + + private var chartBitmap: ChartBitmap + init { + Log.v(LOG_ID, "init") + InternalNotifier.addNotifier(context, this, mutableSetOf(NotifySource.GRAPH_CHANGED)) + chartBitmap = ChartBitmap(context, durationPref, showAxis = showAxis) + imageView.setImageBitmap(chartBitmap.getBitmap()) + } + + fun close() { + Log.v(LOG_ID, "close") + InternalNotifier.remNotifier(context, this) + chartBitmap.close() + } + + @OptIn(DelicateCoroutinesApi::class) + override fun OnNotifyData(context: Context, dataSource: NotifySource, extras: Bundle?) { + Log.d(LOG_ID, "OnNotifyData for $dataSource - id ${chartBitmap.chartId} - extras: ${Utils.dumpBundle(extras)}") + if(dataSource == NotifySource.GRAPH_CHANGED && extras?.getInt(Constants.GRAPH_ID) == chartBitmap.chartId) { + GlobalScope.launch(Dispatchers.Main) { + try { + Log.i(LOG_ID, "Update bitmap for id ${chartBitmap.chartId}") + imageView.setImageBitmap(chartBitmap.getBitmap()) + } catch (exc: Exception) { + Log.e(LOG_ID, "Update bitmap exception: " + exc.toString()) + } + } + } + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartCreator.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartCreator.kt new file mode 100644 index 000000000..6b11674b0 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/ChartCreator.kt @@ -0,0 +1,677 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import androidx.core.view.drawToBitmap +import com.github.mikephil.charting.components.LimitLine +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.listener.OnChartValueSelectedListener +import com.github.mikephil.charting.utils.Utils as ChartUtils +import de.michelinside.glucodatahandler.common.Constants +import de.michelinside.glucodatahandler.common.R +import de.michelinside.glucodatahandler.common.ReceiveData +import de.michelinside.glucodatahandler.common.database.GlucoseValue +import de.michelinside.glucodatahandler.common.database.dbAccess +import de.michelinside.glucodatahandler.common.notifier.InternalNotifier +import de.michelinside.glucodatahandler.common.notifier.NotifierInterface +import de.michelinside.glucodatahandler.common.notifier.NotifySource +import de.michelinside.glucodatahandler.common.utils.Utils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.math.RoundingMode +import java.util.concurrent.TimeUnit +import kotlin.math.abs + + +open class ChartCreator(protected val chart: GlucoseChart, protected val context: Context, protected val durationPref: String = ""): NotifierInterface, + SharedPreferences.OnSharedPreferenceChangeListener { + private var LOG_ID = "GDH.Chart.Creator" + protected var created = false + protected val sharedPref = context.getSharedPreferences(Constants.SHARED_PREF_TAG, Context.MODE_PRIVATE) + protected var init = false + protected var hasTimeNotifier = false + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + //private var createJob: Job? = null + private var dataSyncJob: Job? = null + protected open val resetChart = false + protected open var durationHours = 4 + protected open val yAxisOffset = 0F + protected open val yAxisLeftOffset = -15F + protected open val yAxisInterval = 50F + protected open val circleRadius = 2F + protected open val touchEnabled = true + protected open val graphStartTime = 0L + + private var graphPrefList = mutableSetOf( + Constants.SHARED_PREF_LOW_GLUCOSE, + Constants.SHARED_PREF_HIGH_GLUCOSE, + Constants.SHARED_PREF_TARGET_MIN, + Constants.SHARED_PREF_TARGET_MAX, + Constants.SHARED_PREF_COLOR_ALARM, + Constants.SHARED_PREF_COLOR_OUT_OF_RANGE, + Constants.SHARED_PREF_COLOR_OK, + Constants.SHARED_PREF_SHOW_OTHER_UNIT + ) + + private fun init() { + if(!init) { + LOG_ID = "GDH.Chart.Creator." + chart.id.toString() + Log.d(LOG_ID, "init") + ChartUtils.init(context) + if(durationPref.isNotEmpty()) { + durationHours = sharedPref.getInt(durationPref, durationHours) + } + sharedPref.registerOnSharedPreferenceChangeListener(this) + updateNotifier() + init = true + } + } + + protected fun updateNotifier() { + val hasData = dbAccess.hasGlucoseValues(getMinTime()) + Log.d(LOG_ID, "updateNotifier - has data: $hasData - xAxisEnabled: ${chart.xAxis.isEnabled}") + if(hasData || chart.xAxis.isEnabled) { + InternalNotifier.addNotifier(context, this, mutableSetOf(NotifySource.TIME_VALUE)) + hasTimeNotifier = true + } else { + InternalNotifier.remNotifier(context, this) + hasTimeNotifier = false + } + } + + private fun waitForCreation() { + /*if(createJob?.isActive == true) { + Log.d(LOG_ID, "waitForCreation - wait for current execution") + runBlocking { + createJob!!.join() + } + }*/ + } + + fun create(initData: Boolean = false) { + waitForCreation() + try { + Log.d(LOG_ID, "create") + stopDataSync() + init() + resetChart() + initXaxis() + initYaxis() + initChart() + if(initData) { + initData() + } else { + initDataSet() + } + created = true + Log.d(LOG_ID, "create done") + } catch (exc: Exception) { + Log.e(LOG_ID, "create exception: " + exc.message + " - " + exc.stackTraceToString()) + } + } + + fun pause() { + try { + stopDataSync() + } catch (exc: Exception) { + Log.e(LOG_ID, "pause exception: " + exc.message.toString() ) + } + } + + fun resume() { + try { + if(!startDataSync()) { + if(chart.xAxis.axisMinimum > chart.xAxis.axisMaximum) + chart.postInvalidate() // need to redraw the chart + } + } catch (exc: Exception) { + Log.e(LOG_ID, "resume exception: " + exc.message.toString() ) + } + } + + fun close() { + Log.d(LOG_ID, "close init: $init") + try { + if(init) { + InternalNotifier.remNotifier(context, this) + sharedPref.unregisterOnSharedPreferenceChangeListener(this) + stopDataSync() + waitForCreation() + init = false + } + } catch (exc: Exception) { + Log.e(LOG_ID, "close exception: " + exc.message.toString() ) + } + } + + fun isRight(): Boolean { + return chart.highestVisibleX.toInt() == chart.xChartMax.toInt() + } + + fun isLeft(): Boolean { + return chart.lowestVisibleX.toInt() == chart.xChartMin.toInt() + } + + private fun resetChart() { + Log.v(LOG_ID, "resetChart") + //chart.highlightValue(null) + chart.fitScreen() + chart.data?.clearValues() + chart.data?.notifyDataChanged() + chart.axisRight.removeAllLimitLines() + chart.xAxis.valueFormatter = null + chart.axisRight.valueFormatter = null + chart.axisLeft.valueFormatter = null + chart.marker = null + chart.notifyDataSetChanged() + chart.clear() + } + + protected open fun initXaxis() { + Log.v(LOG_ID, "initXaxis") + chart.xAxis.position = XAxis.XAxisPosition.BOTTOM + chart.xAxis.setDrawGridLines(true) + chart.xAxis.enableGridDashedLine(10F, 10F, 0F) + chart.xAxis.valueFormatter = TimeValueFormatter(chart) + chart.setXAxisRenderer(TimeAxisRenderer(chart)) + chart.xAxis.textColor = context.resources.getColor(R.color.text_color) + } + + protected open fun initYaxis() { + Log.v(LOG_ID, "initYaxis") + chart.axisRight.valueFormatter = GlucoseFormatter() + chart.axisRight.setDrawZeroLine(false) + chart.axisRight.setDrawAxisLine(false) + chart.axisRight.setDrawGridLines(false) + chart.axisRight.xOffset = yAxisOffset + chart.axisRight.textColor = context.resources.getColor(R.color.text_color) + + chart.axisLeft.isEnabled = showOtherUnit() + if(chart.axisLeft.isEnabled) { + chart.axisLeft.valueFormatter = GlucoseFormatter(true) + chart.axisLeft.setDrawZeroLine(false) + chart.axisLeft.setDrawAxisLine(false) + chart.axisLeft.setDrawGridLines(false) + chart.axisLeft.xOffset = yAxisLeftOffset + chart.axisLeft.textColor = context.resources.getColor(R.color.text_color) + } + } + + private fun createLimitLine(limit: Float): LimitLine { + Log.v(LOG_ID, "Create limit line for limit: $limit") + val line = LimitLine(limit) + line.lineColor = context.resources.getColor(R.color.chart_limit_line_color) + return line + } + + protected fun initDataSet() { + if(chart.data == null || chart.data.dataSetCount == 0) { + Log.v(LOG_ID, "initDataSet") + val dataSet = LineDataSet(ArrayList(), "Glucose Values") + //dataSet.valueFormatter = GlucoseFormatter() + //dataSet.colors = mutableListOf() + dataSet.circleColors = mutableListOf() + //dataSet.lineWidth = 1F + dataSet.circleRadius = circleRadius + dataSet.setDrawValues(false) + dataSet.setDrawCircleHole(false) + dataSet.axisDependency = YAxis.AxisDependency.RIGHT + dataSet.enableDashedLine(0F, 1F, 0F) + chart.data = LineData(dataSet) + chart.notifyDataSetChanged() + Log.v(LOG_ID, "Min: ${chart.xAxis.axisMinimum} - visible: ${chart.lowestVisibleX} - Max: ${chart.xAxis.axisMaximum} - visible: ${chart.highestVisibleX}") + } + } + + protected fun updateYAxisLabelCount() { + if(chart.axisRight.isDrawLabelsEnabled && yAxisInterval > 0F) { + val count = Utils.round(chart.axisRight.axisMaximum / yAxisInterval, 0, RoundingMode.DOWN).toInt() + if(count != chart.axisRight.labelCount) { + Log.v(LOG_ID, "update y-axis label count: $count for ${chart.axisRight.axisMaximum}") + chart.axisRight.setLabelCount(count) + if(chart.axisLeft.isEnabled) + chart.axisLeft.setLabelCount(count) + } + } + } + + protected open fun initChart() { + Log.v(LOG_ID, "initChart - touchEnabled: $touchEnabled") + chart.setTouchEnabled(false) + initDataSet() + chart.setBackgroundColor(Color.TRANSPARENT) + chart.isAutoScaleMinMaxEnabled = false + chart.legend.isEnabled = false + chart.description.isEnabled = false + chart.isScaleYEnabled = false + chart.minOffset = 0F + chart.setExtraOffsets(4F, 4F, 4F, 4F) + if(touchEnabled) { + val mMarker = CustomBubbleMarker(context, true) + mMarker.chartView = chart + chart.marker = mMarker + chart.setOnChartValueSelectedListener(object: OnChartValueSelectedListener { + override fun onValueSelected(e: Entry?, h: Highlight?) { + Log.v(LOG_ID, "onValueSelected: ${e?.x} - ${e?.y} - index: ${h?.dataSetIndex}") + if(h?.dataSetIndex == 1) { + // do not select time line + chart.highlightValue(null) + } + } + override fun onNothingSelected() { + Log.v(LOG_ID, "onNothingSelected") + } + }) + chart.setOnLongClickListener { + chart.highlightValue(null) + true + } + } + chart.axisRight.removeAllLimitLines() + + if(ReceiveData.highRaw > 0F) + chart.axisRight.addLimitLine(createLimitLine(ReceiveData.highRaw)) + if(ReceiveData.lowRaw > 0F) + chart.axisRight.addLimitLine(createLimitLine(ReceiveData.lowRaw)) + if(ReceiveData.targetMinRaw > 0F) + chart.axisRight.addLimitLine(createLimitLine(ReceiveData.targetMinRaw)) + if(ReceiveData.targetMaxRaw > 0F) + chart.axisRight.addLimitLine(createLimitLine(ReceiveData.targetMaxRaw)) + chart.axisRight.axisMinimum = Constants.GLUCOSE_MIN_VALUE.toFloat()-10F + chart.axisRight.axisMaximum = getDefaultMaxValue() + chart.axisLeft.axisMinimum = chart.axisRight.axisMinimum + chart.axisLeft.axisMaximum = chart.axisRight.axisMaximum + updateYAxisLabelCount() + chart.isScaleXEnabled = false + } + + protected open fun getDefaultMaxValue() : Float { + return maxOf(ReceiveData.highRaw, dbAccess.getMaxValue(getMinTime()).toFloat()) + 10F + } + + protected fun stopDataSync() { + if(dataSyncJob != null && dataSyncJob!!.isActive) { + dataSyncJob!!.cancel() + if(dataSyncJob!!.isActive) { + runBlocking { + Log.d(LOG_ID, "stop data sync - wait for current execution") + dataSyncJob!!.join() + } + } + } + } + + private fun initData() { + initDataSet() + startDataSync() + } + + private fun startDataSync(): Boolean { + if(dataSyncJob?.isActive != true) { + Log.v(LOG_ID, "startDataSync") + dataSyncJob = scope.launch { + dataSync() + } + return true + } + return false + } + + private suspend fun dataSync() { + Log.d(LOG_ID, "dataSync running") + try { + if(getMaxRange() > 0L) { + dbAccess.getLiveValuesByTimeSpan(getMaxRange().toInt()/60).collect{ values -> + update(values) + } + } + else if(graphStartTime > 0L) { + dbAccess.getLiveValuesByStartTime(graphStartTime).collect{ values -> + update(values) + } + } else { + dbAccess.getLiveValues().collect{ values -> + update(values) + } + } + Log.d(LOG_ID, "dataSync done") + } catch (exc: CancellationException) { + Log.d(LOG_ID, "dataSync cancelled") + } catch (exc: Exception) { + Log.e(LOG_ID, "dataSync exception: " + exc.message.toString() + "\n" + exc.stackTraceToString()) + } + } + + protected open fun getMaxRange(): Long { + return 0L + } + + protected fun getMinTime(): Long { + if(getMaxRange() > 0L) + return System.currentTimeMillis() - (getMaxRange()*60*1000L) + return graphStartTime + } + + protected open fun getDefaultRange(): Long { + return durationHours * 60L + } + + private fun addEntries(values: List) { + Log.d(LOG_ID, "Add ${values.size} entries") + val dataSet = chart.data.getDataSetByIndex(0) as LineDataSet + var added = false + if(values.isNotEmpty()) { + if(dataSet.values.isEmpty()) { + Log.v(LOG_ID, "Reset colors") + dataSet.resetCircleColors() + } + for (element in values) { + val entry = Entry(TimeValueFormatter.to_chart_x(element.timestamp), element.value.toFloat()) + if (dataSet.values.isEmpty() || dataSet.values.last().x < entry.x) { + added = true + dataSet.addEntry(entry) + val color = ReceiveData.getValueColor(entry.y.toInt()) + //dataSet.addColor(color) + dataSet.circleColors.add(color) + + if (chart.axisRight.axisMinimum > (entry.y - 10F)) { + chart.axisRight.axisMinimum = entry.y - 10F + chart.axisLeft.axisMinimum = chart.axisRight.axisMinimum + updateYAxisLabelCount() + } + if (chart.axisRight.axisMaximum < (entry.y + 10F)) { + chart.axisRight.axisMaximum = entry.y + 10F + chart.axisLeft.axisMaximum = chart.axisRight.axisMaximum + updateYAxisLabelCount() + } + } + } + } + + if(added) { + dataSet.notifyDataSetChanged() + updateChart(dataSet) + if(touchEnabled) + chart.enableTouch() + } else { + updateChart(null) // update chart for time data-change + } + } + + protected fun addEmptyTimeData() { + Log.v(LOG_ID, "addEmptyTimeData - entries: ${chart.data?.entryCount}") + if(chart.data?.entryCount == 0 && !chart.xAxis.isEnabled) { + if(hasTimeNotifier) + updateNotifier() + return // no data and not xAxis, do not add the time line, as it is not needed + } + if(!hasTimeNotifier) + updateNotifier() + var minTime = getMinTime() + if (minTime == 0L) // at least one hour should be shown + minTime = System.currentTimeMillis() - (60 * 60 * 1000L) + + var minValue = 0L + var maxValue = 0L + + if(chart.data != null && chart.data.dataSetCount > 0) { + val dataSet = chart.data.getDataSetByIndex(0) as LineDataSet + val values = dataSet.values.toList() + if (values.isEmpty()) { + Log.v(LOG_ID, "No values, set min and max") + minValue = minTime + maxValue = System.currentTimeMillis() + } else { + Log.v( + LOG_ID, + "Min time: ${Utils.getUiTimeStamp(minTime)} - first value: ${ + Utils.getUiTimeStamp(TimeValueFormatter.from_chart_x(values.first().x)) + } - last value: ${Utils.getUiTimeStamp(TimeValueFormatter.from_chart_x(values.last().x))}" + ) + if (TimeValueFormatter.from_chart_x(values.first().x) > minTime) { + minValue = minTime + } + if (Utils.getElapsedTimeMinute(TimeValueFormatter.from_chart_x(values.last().x)) >= 1) { + maxValue = System.currentTimeMillis() + } + } + } + if(minValue > 0 || maxValue > 0) { + Log.d(LOG_ID, "Creating extra data set from ${Utils.getUiTimeStamp(minValue)} - ${Utils.getUiTimeStamp(maxValue)}") + val entries = ArrayList() + if(minValue > 0) + entries.add(Entry(TimeValueFormatter.to_chart_x(minValue), 120F)) + if(maxValue > 0) + entries.add(Entry(TimeValueFormatter.to_chart_x(maxValue), 120F)) + + val timeSet = LineDataSet(entries, "Time line") + timeSet.lineWidth = 1F + timeSet.circleRadius = 1F + timeSet.setDrawValues(false) + timeSet.setDrawCircleHole(false) + timeSet.setCircleColors(0) + timeSet.setColors(0) + chart.data.addDataSet(timeSet) + } + } + + protected open fun updateChart(dataSet: LineDataSet?) { + val right = isRight() + val left = isLeft() + Log.v(LOG_ID, "Min: ${chart.xAxis.axisMinimum} - visible: ${chart.lowestVisibleX} - Max: ${chart.xAxis.axisMaximum} - visible: ${chart.highestVisibleX} - isLeft: ${left} - isRight: ${right}" ) + val diffTimeMin = TimeUnit.MILLISECONDS.toMinutes(TimeValueFormatter.from_chart_x(chart.highestVisibleX) - TimeValueFormatter.from_chart_x(chart.lowestVisibleX)) + if(!chart.highlighted.isNullOrEmpty() && chart.highlighted[0].dataSetIndex != 0) { + Log.v(LOG_ID, "Unset current highlighter") + chart.highlightValue(null) + } + if(dataSet != null) + chart.data = LineData(dataSet) + addEmptyTimeData() + chart.notifyDataSetChanged() + invalidateChart(diffTimeMin, right, left) + } + + protected open fun invalidateChart(diffTime: Long, right: Boolean, left: Boolean) { + val defaultRange = getDefaultRange() + var diffTimeMin = diffTime + val newDiffTime = TimeUnit.MILLISECONDS.toMinutes( TimeValueFormatter.from_chart_x(chart.xChartMax) - TimeValueFormatter.from_chart_x(chart.lowestVisibleX)) + Log.d(LOG_ID, "Diff-Time: ${diffTimeMin} minutes - newDiffTime: ${newDiffTime} minutes") + var setXRange = false + if(!chart.isScaleXEnabled && newDiffTime >= minOf(defaultRange, 90)) { + Log.d(LOG_ID, "Enable X scale") + chart.isScaleXEnabled = true + setXRange = true + } + + if((right && left && diffTimeMin < defaultRange && newDiffTime >= defaultRange)) { + Log.v(LOG_ID, "Set ${defaultRange/60} hours diff time") + diffTimeMin = defaultRange + } + + if(right) { + if(diffTimeMin >= defaultRange || !left) { + Log.d(LOG_ID, "Fix interval: $diffTimeMin") + chart.setVisibleXRangeMaximum(diffTimeMin.toFloat()) + setXRange = true + } + Log.v(LOG_ID, "moveViewToX ${chart.xChartMax}") + chart.moveViewToX(chart.xChartMax) + } /*else { + Log.v(LOG_ID, "Invalidate chart") + chart.setVisibleXRangeMaximum(diffTimeMin.toFloat()) + setXRange = true + chart.postInvalidate() + }*/ + + if(setXRange) { + chart.setVisibleXRangeMinimum(60F) + chart.setVisibleXRangeMaximum(60F*24F) + } + } + + private fun getFirstTimestamp(): Float { + if(chart.data != null && chart.data.dataSetCount > 0) { + val dataSet = chart.data.getDataSetByIndex(0) as LineDataSet + if (dataSet.values.isNotEmpty()) { + return dataSet.values.first().x + } + } + return 0F + } + + private fun getEntryCount(): Int { + if(chart.data != null && chart.data.dataSetCount > 0) { + val dataSet = chart.data.getDataSetByIndex(0) as LineDataSet + return dataSet.entryCount + } + return 0 + } + + private fun getLastTimestamp(): Long { + if(chart.data != null && chart.data.dataSetCount > 0) { + val dataSet = chart.data.getDataSetByIndex(0) as LineDataSet + if (dataSet.values.isNotEmpty()) { + return TimeValueFormatter.from_chart_x(dataSet.values.last().x) + } + } + return 0L + } + + private fun checkValues(values: List): Boolean { + var lastTime = 0L + val deleteValues = mutableListOf() + values.forEach { + if(Utils.getTimeDiffMinute(it.timestamp, lastTime, RoundingMode.HALF_UP) == 0L) { + deleteValues.add(it.timestamp) + } + lastTime = it.timestamp + } + if(deleteValues.isNotEmpty()) { + Log.i(LOG_ID, "Delete ${deleteValues.size} duplicate values") + dbAccess.deleteValues(deleteValues) + return false + } + return true + } + + protected fun update(values: List) { + try { + if(!checkValues(values)) + return + if(values.isNotEmpty()) { + Log.d(LOG_ID, "update called for ${values.size} values - resetChart: $resetChart - entries: ${getEntryCount()} - first value: ${TimeValueFormatter.to_chart_x(values.first().timestamp)} - first: ${getFirstTimestamp()}") + if(resetChart || TimeValueFormatter.to_chart_x(values.first().timestamp) != getFirstTimestamp() || (getEntryCount() > 0 && abs(values.size-getEntryCount()) > 1)) { + resetData(values) + return + } + val newValues = values.filter { data -> data.timestamp > getLastTimestamp() } + addEntries(newValues) + } else if(getEntryCount() > 0) { + Log.d(LOG_ID, "Reset chart after db clean up") + create(true) + } else { + addEntries(values) + } + } catch (exc: Exception) { + Log.e(LOG_ID, "update exception: " + exc.message.toString() + " - " + exc.stackTraceToString() ) + } + } + + protected open fun updateTimeElapsed() { + Log.v(LOG_ID, "update time elapsed") + updateChart(null) + } + + protected fun resetChartData() { + if(chart.data != null && chart.data.dataSetCount > 0) { + Log.d(LOG_ID, "Reset chart data") + //chart.highlightValue(null) + val dataSet = chart.data.getDataSetByIndex(0) as LineDataSet + dataSet.clear() + dataSet.setColors(0) + dataSet.setCircleColor(0) + chart.data.clearValues() + chart.data.notifyDataChanged() + chart.notifyDataSetChanged() + } + } + + protected fun resetData(values: List) { + Log.d(LOG_ID, "Reset data") + resetChartData() + initDataSet() + addEntries(values) + } + + override fun OnNotifyData(context: Context, dataSource: NotifySource, extras: Bundle?) { + Log.d(LOG_ID, "OnNotifyData: $dataSource") + if(!init) { + Log.w(LOG_ID, "Chart still not init - create!") + create(true) + } + if(dataSource == NotifySource.TIME_VALUE) { + Log.d(LOG_ID, "time elapsed: ${ReceiveData.getElapsedTimeMinute()}") + if(ReceiveData.getElapsedTimeMinute().mod(2) == 0) { + waitForCreation() + //createJob = scope.launch { + try { + updateTimeElapsed() + } catch (exc: Exception) { + Log.e(LOG_ID, "OnNotifyData exception: " + exc.message.toString() + " - " + exc.stackTraceToString() ) + } + //} + } + } + } + + protected fun createBitmap(): Bitmap? { + try { + chart.waitForInvalidate() + if(chart.width > 0 && chart.height > 0) { + Log.d(LOG_ID, "Draw bitmap") + return chart.drawToBitmap() + } + } catch (exc: Exception) { + Log.e(LOG_ID, "getBitmap exception: " + exc.message.toString() ) + } + return null + } + + protected open fun showOtherUnit(): Boolean { + return sharedPref.getBoolean(Constants.SHARED_PREF_SHOW_OTHER_UNIT, false) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + Log.d(LOG_ID, "onSharedPreferenceChanged: $key") + try { + if(key == durationPref) { + durationHours = sharedPref.getInt(durationPref, durationHours) + stopDataSync() + resetChartData() + chart.fitScreen() + initData() + } else if (graphPrefList.contains(key)) { + Log.i(LOG_ID, "re create graph after settings changed for key: $key") + ReceiveData.updateSettings(sharedPref) + if(chart.data != null && chart.data.getDataSetByIndex(0).entryCount > 0) + create(true) // recreate chart with new graph data + } + } catch (exc: Exception) { + Log.e(LOG_ID, "onSharedPreferenceChanged exception: " + exc.message.toString() ) + } + } +} diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/CustomBubbleMarker.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/CustomBubbleMarker.kt new file mode 100644 index 000000000..92f91693a --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/CustomBubbleMarker.kt @@ -0,0 +1,147 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.text.format.DateUtils +import android.util.Log +import android.widget.TextView +import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.content.ContextCompat +import com.github.mikephil.charting.components.MarkerView +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.utils.MPPointF +import de.michelinside.glucodatahandler.common.R +import java.text.DateFormat +import java.util.Date + +class CustomBubbleMarker(context: Context, private val showDate: Boolean = false) : MarkerView(context, R.layout.marker_layout) { + private val LOG_ID = "GDH.Chart.MarkerView" + private val arrowSize = 35 + private val arrowCircleOffset = 0f + private var isGlucose = true + + override fun refreshContent(e: Entry?, highlight: Highlight) { + Log.v(LOG_ID, "refreshContent - index ${highlight.dataSetIndex}") + try { + isGlucose = highlight.dataSetIndex == 0 + e?.let { + val timeValue = TimeValueFormatter.from_chart_x(e.x) + val dateValue = Date(timeValue) + val date: TextView = this.findViewById(R.id.date) + date.visibility = if(showDate && !DateUtils.isToday(timeValue)) VISIBLE else GONE + val time: TextView = this.findViewById(R.id.time) + val glucose: TextView = this.findViewById(R.id.glucose) + val layout: LinearLayoutCompat = this.findViewById(R.id.marker_layout) + if(isGlucose) { + date.text = DateFormat.getDateInstance(DateFormat.SHORT).format(dateValue) + time.text = DateFormat.getTimeInstance(DateFormat.DEFAULT).format(dateValue) + glucose.text = GlucoseFormatter.getValueAsString(e.y) + layout.visibility = VISIBLE + } else { + date.text = "" + time.text = "" + glucose.text = "" + layout.visibility = GONE + } + } + } catch (exc: Exception) { + Log.e(LOG_ID, "Exception in refreshContent", exc) + } + super.refreshContent(e, highlight) + } + + override fun getOffsetForDrawingAtPoint(posX: Float, posY: Float): MPPointF { + Log.v(LOG_ID, "getOffsetForDrawingAtPoint - isGlucose: $isGlucose") + val offset = offset + try { + val chart = chartView + val width = width.toFloat() + val height = height.toFloat() + + if (posY <= height + arrowSize) { + offset.y = arrowSize.toFloat() + } else { + offset.y = -height - arrowSize + } + + if (posX > chart.width - width) { + offset.x = -width + } else { + offset.x = 0f + if (posX > width / 2) { + offset.x = -(width / 2) + } + } + } catch (exc: Exception) { + Log.e(LOG_ID, "Exception in getOffsetForDrawingAtPoint", exc) + } + + return offset + } + + override fun draw(canvas: Canvas, posX: Float, posY: Float) { + Log.v(LOG_ID, "draw - isGlucose: $isGlucose") + try { + val paint = Paint().apply { + style = Paint.Style.FILL + strokeJoin = Paint.Join.ROUND + color = if(isGlucose) ContextCompat.getColor(context, R.color.transparent_marker_background) else 0 + } + + val chart = chartView + val width = width.toFloat() + val height = height.toFloat() + val offset = getOffsetForDrawingAtPoint(posX, posY) + val saveId: Int = canvas.save() + val path = Path() + + if (posY < height + arrowSize) { + if (posX > chart.width - width) { + path.moveTo(width - (2 * arrowSize), 2f) + path.lineTo(width, -arrowSize + arrowCircleOffset) + path.lineTo(width - arrowSize, 2f) + } else { + if (posX > width / 2) { + path.moveTo(width / 2 - arrowSize / 2, 2f) + path.lineTo(width / 2, -arrowSize + arrowCircleOffset) + path.lineTo(width / 2 + arrowSize / 2, 2f) + } else { + path.moveTo(0f, -arrowSize + arrowCircleOffset) + path.lineTo(0f + arrowSize, 2f) + path.lineTo(0f, 2f) + path.lineTo(0f, -arrowSize + arrowCircleOffset) + } + } + path.offset(posX + offset.x, posY + offset.y) + } else { + if (posX > chart.width - width) { + path.moveTo(width, (height - 2) + arrowSize - arrowCircleOffset) + path.lineTo(width - arrowSize, height - 2) + path.lineTo(width - (2 * arrowSize), height - 2) + } else { + if (posX > width / 2) { + path.moveTo(width / 2 + arrowSize / 2, height - 2) + path.lineTo(width / 2, (height - 2) + arrowSize - arrowCircleOffset) + path.lineTo(width / 2 - arrowSize / 2, height - 2) + path.lineTo(0f, height - 2) + } else { + path.moveTo(0f + (arrowSize * 2), height - 2) + path.lineTo(0f, (height - 2) + arrowSize - arrowCircleOffset) + path.lineTo(0f, height - 2) + path.lineTo(0f + arrowSize, height - 2) + } + } + path.offset(posX + offset.x, posY + offset.y) + } + canvas.drawPath(path, paint) + canvas.translate(posX + offset.x, posY + offset.y) + draw(canvas) + canvas.restoreToCount(saveId) + } catch (exc: Exception) { + Log.e(LOG_ID, "Exception in getOffsetForDrawingAtPoint", exc) + } + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/CustomMarkerView.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/CustomMarkerView.kt new file mode 100644 index 000000000..0292fba68 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/CustomMarkerView.kt @@ -0,0 +1,37 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.TextView +import com.github.mikephil.charting.components.MarkerView +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.utils.MPPointF +import de.michelinside.glucodatahandler.common.R +import java.text.DateFormat +import java.util.Date + + +@SuppressLint("ViewConstructor") +class CustomMarkerView(context: Context): MarkerView(context, R.layout.custom_marker_view_layout) { + private val LOG_ID = "GDH.Chart.MarkerView" + // this markerview only displays a textview + private val time = findViewById(R.id.time) as TextView + private val glucose = findViewById(R.id.glucose) as TextView + + // callbacks everytime the MarkerView is redrawn, can be used to update the + // content (user-interface) + override fun refreshContent(e: Entry?, highlight: Highlight?) { + Log.v(LOG_ID, "refreshContent for $e") + super.refreshContent(e, highlight) + time.text = DateFormat.getTimeInstance(DateFormat.DEFAULT).format(Date(TimeValueFormatter.from_chart_x(e!!.x))) + glucose.text = GlucoseFormatter.getValueAsString(e.y) + } + + override fun getOffset(): MPPointF { + return MPPointF((-(width / 2)).toFloat(), (-height).toFloat()) + } + +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/GlucoseChart.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/GlucoseChart.kt new file mode 100644 index 000000000..72efe9e72 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/GlucoseChart.kt @@ -0,0 +1,108 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.content.Context +import android.graphics.Canvas +import android.os.Looper +import android.util.AttributeSet +import android.util.Log +import android.view.View +import com.github.mikephil.charting.charts.LineChart + +class GlucoseChart: LineChart { + private var LOG_ID = "GDH.Chart.GlucoseChart" + + constructor(context: Context?) : super(context) + + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + context, + attrs, + defStyle + ) + + private var isInvalidating = false + private var isDrawing = false + init { + LOG_ID = "GDH.Chart.GlucoseChart." + id.toString() + } + + override fun getId(): Int { + if(super.getId() == View.NO_ID) { + id = generateViewId() + } + return super.getId() + } + + override fun postInvalidate() { + Log.v(LOG_ID, "postInvalidate") + isInvalidating = true + isDrawing = isShown + super.postInvalidate() + Log.v(LOG_ID, "postInvalidate done") + } + + override fun invalidate() { + isInvalidating = true + isDrawing = isShown + try { + Log.v(LOG_ID, "invalidate - shown: $isShown") + super.invalidate() + } catch (exc: Exception) { + Log.e(LOG_ID, "invalidate exception: ${exc.message}\n${exc.stackTraceToString()}") + } + isInvalidating = false + } + + fun waitForInvalidate() { + if(isInvalidating && Looper.myLooper() != Looper.getMainLooper()) { + Log.d(LOG_ID, "waitForInvalidate") + while(isInvalidating) { + Thread.sleep(10) + } + } + } + + fun waitForDrawing() { + if(isDrawing && Looper.myLooper() != Looper.getMainLooper()) { + Log.d(LOG_ID, "waitForDrawing") + while(isDrawing) { + Thread.sleep(10) + } + } + } + + fun enableTouch() { + if(!mTouchEnabled) { + Log.d(LOG_ID, "enable touch") + waitForDrawing() + setTouchEnabled(true) + } + } + + override fun onDetachedFromWindow() { + try { + super.onDetachedFromWindow() + } catch (exc: Exception) { + Log.e(LOG_ID, "onDetachedFromWindow exception: ${exc.message}\n${exc.stackTraceToString()}") + } + Log.d(LOG_ID, "onDetachedFromWindow for ID: $id") + } + + override fun onDraw(canvas: Canvas) { + try { + isDrawing = true + Log.v(LOG_ID, "onDraw - min: ${xAxis.axisMinimum} - max: ${xAxis.axisMaximum}") + super.onDraw(canvas) + Log.v(LOG_ID, "drawn") + } catch (exc: Exception) { + if(xAxis.axisMinimum > xAxis.axisMaximum) { // ignore this, as it is caused during reset and draw... + Log.w(LOG_ID, "onDraw exception: ${exc.message}") + } else { + Log.e(LOG_ID, "onDraw exception: ${exc.message}\n${exc.stackTraceToString()}") + } + } + isDrawing = false + } + +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/GlucoseFormatter.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/GlucoseFormatter.kt new file mode 100644 index 000000000..0468a5785 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/GlucoseFormatter.kt @@ -0,0 +1,18 @@ +package de.michelinside.glucodatahandler.common.chart + +import com.github.mikephil.charting.formatter.ValueFormatter +import de.michelinside.glucodatahandler.common.ReceiveData +import de.michelinside.glucodatahandler.common.utils.GlucoDataUtils + +class GlucoseFormatter(val inverted: Boolean = false): ValueFormatter() { + companion object { + fun getValueAsString(value: Float, inverted: Boolean = false): String { + if((!inverted && ReceiveData.isMmol) || (inverted && !ReceiveData.isMmol)) + return GlucoDataUtils.mgToMmol(value).toString() + return value.toInt().toString() + } + } + override fun getFormattedValue(value: Float): String { + return getValueAsString(value, inverted) + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/chart/TimeAxisRenderer.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/TimeAxisRenderer.kt new file mode 100644 index 000000000..ed1648046 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/chart/TimeAxisRenderer.kt @@ -0,0 +1,150 @@ +package de.michelinside.glucodatahandler.common.chart + +import android.util.Log +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.renderer.XAxisRenderer +import com.github.mikephil.charting.utils.Utils +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +class TimeAxisRenderer(chart: LineChart) : + XAxisRenderer(chart.viewPortHandler, chart.xAxis, chart.rendererXAxis.transformer) { + private val LOG_ID = "GDH.Chart.TimeAxisRenderer" + + + override fun computeAxisValues(min: Float, max: Float) { + computeNiceAxisValues(min, max) + super.computeSize() + } + + // Custom method for calculating a "nice" interval at the + // desired display time unit + private fun calculateInterval(min: Float, max: Float, count: Int): Float { + val range = (max - min).toDouble() + + val unit = TimeValueFormatter.getUnit(min, max) + val axisScale = unit.factor + + //Log.d(LOG_ID, "Calculate interval: $unit - scale: $axisScale - min: $min - max: $max - range: $range - count: $count") + + // Find out how much spacing (in y value space) between axis values + val rawInterval = range / count / axisScale + var interval: Float = Utils.roundToNextSignificant(rawInterval) + // If granularity is enabled, then do not allow the interval to go below specified granularity. + // This is used to avoid repeated values when rounding values for display. + if (mAxis.isGranularityEnabled) { + val gran = mAxis.granularity * axisScale + Log.d(LOG_ID, "Calculate interval: $interval - gran: $gran") + interval = if (interval < gran) gran else interval + } + + // Normalize interval + val intervalMagnitude: Float = + Utils.roundToNextSignificant(10.0.pow(log10(interval).toInt().toDouble())) + val intervalSigDigit = (interval / intervalMagnitude).toInt() + if (intervalSigDigit > 5) { + // Use one order of magnitude higher, to avoid intervals like 0.9 or + // 90 + interval = floor(10 * intervalMagnitude) + } + + interval *= axisScale + //Log.d(LOG_ID, "Calculate interval: $interval - scale: $axisScale - intervalMagnitude: $intervalMagnitude - intervalSigDigit: $intervalSigDigit") + return (interval/10).toInt()*10F + } + + private fun computeNiceAxisValues(xMin: Float, xMax: Float) { + val labelCount = mAxis.labelCount + val range = (xMax - xMin).toDouble() + + if (labelCount == 0 || range <= 0 || java.lang.Double.isInfinite(range)) { + mAxis.mEntries = floatArrayOf() + mAxis.mCenteredEntries = floatArrayOf() + mAxis.mEntryCount = 0 + return + } + + // == Use a time-aware interval calculation + var interval = calculateInterval(xMin, xMax, labelCount) + + // == Below here copied from AxisRenderer::computeAxisValues unchanged ============ + var n = if (mAxis.isCenterAxisLabelsEnabled) 1 else 0 + + // force label count + if (mAxis.isForceLabelsEnabled) { + interval = (range.toFloat() / (labelCount - 1).toFloat()) + mAxis.mEntryCount = labelCount + + if (mAxis.mEntries.size < labelCount) { + // Ensure stops contains at least numStops elements. + mAxis.mEntries = FloatArray(labelCount) + } + + var v = xMin + + for (i in 0..) + + @Query("SELECT * FROM glucose_values ORDER BY timestamp") + fun getValues(): List + + @Query("SELECT * FROM glucose_values WHERE timestamp >= :minTime ORDER BY timestamp") + fun getValuesByTime(minTime: Long): List + + @Query("SELECT * FROM glucose_values ORDER BY timestamp") + fun getLiveValues(): Flow> + + @Query("SELECT * FROM glucose_values WHERE timestamp >= strftime('%s', 'now', '-' || :hours || ' hours') * 1000 ORDER BY timestamp") + fun getLiveValuesByTimeSpan(hours: Int): Flow> + + @Query("SELECT * FROM glucose_values WHERE timestamp >= :minTime ORDER BY timestamp") + fun getLiveValuesByStartTime(minTime: Long): Flow> + + @Query("SELECT timestamp from glucose_values ORDER BY timestamp DESC LIMIT 1") + fun getLastTimestamp(): Long + + @Query("SELECT timestamp from glucose_values ORDER BY timestamp ASC LIMIT 1") + fun getFirstTimestamp(): Long + + @Query("SELECT COUNT(*) from glucose_values") + fun getCount(): Int + + @Query("SELECT COUNT(*) from glucose_values WHERE timestamp >= :minTime") + fun getCountByTime(minTime: Long): Int + + @Query("SELECT MAX(value) FROM glucose_values") + fun getMaxValue(): Int + + @Query("SELECT MAX(value) FROM glucose_values WHERE timestamp >= :minTime") + fun getMaxValueByTime(minTime: Long): Int + + @Query("DELETE FROM glucose_values WHERE timestamp = :timestamp") + fun deleteValue(timestamp: Long) + + @Query("DELETE FROM glucose_values WHERE timestamp IN (:timestamps)") + fun deleteValues(timestamps: List) + + @Query("DELETE FROM glucose_values WHERE timestamp < :minTime") + fun deleteOldValues(minTime: Long) + + @Query("DELETE FROM glucose_values") + fun deleteAllValues() +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/database/dbAccess.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/database/dbAccess.kt new file mode 100644 index 000000000..dad04c348 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/database/dbAccess.kt @@ -0,0 +1,148 @@ +package de.michelinside.glucodatahandler.common.database + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import androidx.room.Room +import de.michelinside.glucodatahandler.common.Constants +import de.michelinside.glucodatahandler.common.GlucoDataService +import de.michelinside.glucodatahandler.common.notifier.InternalNotifier +import de.michelinside.glucodatahandler.common.notifier.NotifySource +import de.michelinside.glucodatahandler.common.receiver.InternalActionReceiver +import de.michelinside.glucodatahandler.common.utils.PackageUtils +import de.michelinside.glucodatahandler.common.utils.Utils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +object dbAccess { + private val LOG_ID = "GDH.dbAccess" + private var database: Database? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun init(context: Context) { + Log.v(LOG_ID, "init") + try { + database = Room.databaseBuilder( + context.applicationContext, + Database::class.java, + "gdh_database" + ).build() + cleanUpOldData() + PackageUtils.registerReceiver(context, InternalActionReceiver(), IntentFilter(Intent.ACTION_DATE_CHANGED)) + } catch (exc: Exception) { + Log.e(LOG_ID, "init exception: " + exc.toString() + ": " + exc.stackTraceToString() ) + } + } + + fun getGlucoseValues(minTime: Long = 0L): List = runBlocking { + scope.async { + if(database != null) { + Log.v(LOG_ID, "getGlucoseValues - minTime: ${Utils.getUiTimeStamp(minTime)}") + database!!.glucoseValuesDao().getValuesByTime(minTime) + } else { + Log.e(LOG_ID, "getGlucoseValues - database is null") + emptyList() + } + }.await() + } + + fun getLiveValues(): Flow> { + return database!!.glucoseValuesDao().getLiveValues() + } + + fun getLiveValuesByStartTime(minTime: Long): Flow> { + return database!!.glucoseValuesDao().getLiveValuesByStartTime(minTime) + } + + fun getLiveValuesByTimeSpan(hours: Int): Flow> { + if(hours > 0) + return database!!.glucoseValuesDao().getLiveValuesByTimeSpan(hours) + return database!!.glucoseValuesDao().getLiveValues() + } + + fun hasGlucoseValues(minTime: Long = 0L): Boolean = runBlocking { + scope.async { + if(database != null) { + Log.v(LOG_ID, "hasGlucoseValues - minTime: ${Utils.getUiTimeStamp(minTime)}") + database!!.glucoseValuesDao().getCountByTime(minTime) > 0 + } else { + false + } + }.await() + } + + fun addGlucoseValue(time: Long, value: Int) { + if(database != null) { + scope.launch { + Log.v(LOG_ID, "Add new value $value at ${Utils.getUiTimeStamp(time)}") + database!!.glucoseValuesDao().insertValue(GlucoseValue(time, value)) + } + } + } + + fun addGlucoseValues(values: List) { + if(database != null && values.isNotEmpty()) { + scope.launch { + Log.v(LOG_ID, "Add ${values.size} values") + database!!.glucoseValuesDao().insertValues(values) + InternalNotifier.notify(GlucoDataService.context!!, NotifySource.GRAPH_DATA_CHANGED, null) + } + } + } + + fun getFirstLastTimestamp(): Pair = runBlocking { + scope.async { + if(database != null) { + val first = database!!.glucoseValuesDao().getFirstTimestamp() + val last = database!!.glucoseValuesDao().getLastTimestamp() + Pair(first, last) + } else { + Pair(0L, 0L) + } + }.await() + } + + fun getMaxValue(minTime: Long = 0L): Int = runBlocking { + scope.async { + if(database != null) { + Log.v(LOG_ID, "getMaxValue - minTime: ${Utils.getUiTimeStamp(minTime)}") + if(minTime == 0L) + database!!.glucoseValuesDao().getMaxValue() + else + database!!.glucoseValuesDao().getMaxValueByTime(minTime) + } else 0 + }.await() + } + + fun deleteValues(timestamps: List) = runBlocking { + if(database != null) { + scope.launch { + Log.d(LOG_ID, "delete - ${timestamps.size} values") + database!!.glucoseValuesDao().deleteValues(timestamps) + } + } + } + + fun deleteAllValues() = runBlocking { + scope.launch { + Log.v(LOG_ID, "deleteAllValues") + database!!.glucoseValuesDao().deleteAllValues() + } + } + + fun deleteOldValues(minTime: Long) { + scope.launch { + Log.v(LOG_ID, "deleteOldValues - minTime: ${Utils.getUiTimeStamp(minTime)}") + database!!.glucoseValuesDao().deleteOldValues(minTime) + } + } + + fun cleanUpOldData() { + deleteOldValues(System.currentTimeMillis()-Constants.DB_MAX_DATA_TIME_MS) + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/database/dbSync.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/database/dbSync.kt new file mode 100644 index 000000000..53f44e881 --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/database/dbSync.kt @@ -0,0 +1,187 @@ +package de.michelinside.glucodatahandler.common.database + +import android.content.Context +import android.util.Log +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.Wearable +import com.google.gson.Gson +import de.michelinside.glucodatahandler.common.AppSource +import de.michelinside.glucodatahandler.common.Command +import de.michelinside.glucodatahandler.common.Constants +import de.michelinside.glucodatahandler.common.GlucoDataService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.io.InputStream +import java.io.InputStreamReader +import java.io.StringWriter + +object dbSync : ChannelClient.ChannelCallback() { + private val LOG_ID = "GDH.dbSync" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var finished = true + private var channel: ChannelClient.Channel? = null + private var retryCount = 0 + + private fun registerChannel(context: Context) { + Log.d(LOG_ID, "registerChannel called") + Wearable.getChannelClient(context).registerChannelCallback(this) + } + + private fun getStringFromInputStream(stream: InputStream?): String { + var n: Int + val buffer = CharArray(1024 * 4) + val reader = InputStreamReader(stream, "UTF8") + val writer = StringWriter() + while (-1 != (reader.read(buffer).also { n = it })) writer.write(buffer, 0, n) + return writer.toString() + } + + override fun onChannelOpened(p0: ChannelClient.Channel) { + try { + super.onChannelOpened(p0) + Log.d(LOG_ID, "onChannelOpened") + channel = p0 + scope.launch { + try { + Log.d(LOG_ID, "receiving...") + val inputStream = Tasks.await(Wearable.getChannelClient(GlucoDataService.context!!).getInputStream(p0)) + Log.d(LOG_ID, "received - read") + val received = getStringFromInputStream(inputStream) + Log.v(LOG_ID, "received data: $received") + val gson = Gson() + val data = gson.fromJson(received, Array::class.java).toList() + Log.i(LOG_ID, "${data.size} values received") + dbAccess.addGlucoseValues(data) + Log.d(LOG_ID, "db data saved") + } catch (exc: Exception) { + Log.e(LOG_ID, "reading input exception: " + exc.message.toString() ) + } + } + } catch (exc: Exception) { + Log.e(LOG_ID, "onChannelOpened exception: " + exc.message.toString() ) + } + } + + override fun onInputClosed(p0: ChannelClient.Channel, i: Int, i1: Int) { + try { + super.onInputClosed(p0, i, i1) + Log.d(LOG_ID, "onInputClosed") + Wearable.getChannelClient(GlucoDataService.context!!).close(p0) + channel = null + finished = true + } catch (exc: Exception) { + Log.e(LOG_ID, "onInputClosed exception: " + exc.message.toString() ) + } + } + + override fun onOutputClosed(p0: ChannelClient.Channel, p1: Int, p2: Int) { + try { + super.onOutputClosed(p0, p1, p2) + Log.d(LOG_ID, "onOutputClosed") + Wearable.getChannelClient(GlucoDataService.context!!).close(p0) + channel = null + finished = true + } catch (exc: Exception) { + Log.e(LOG_ID, "onInputClosed exception: " + exc.message.toString() ) + } + } + + fun sendData(context: Context, nodeId: String) { + try { + Log.i(LOG_ID, "send db data to $nodeId") + finished = false + val channelClient = Wearable.getChannelClient(context) + val channelTask = + channelClient.openChannel(nodeId, Constants.DB_SYNC_CHANNEL_PATH) + channelTask.addOnSuccessListener { channel -> + Thread { + try { + val outputStream = Tasks.await(channelClient.getOutputStream(channel)) + val minTime = System.currentTimeMillis() - (if(GlucoDataService.appSource == AppSource.WEAR_APP) Constants.DB_MAX_DATA_TIME_MS else Constants.DB_MAX_DATA_WEAR_TIME_MS) // from phone to wear, only send the last 24h + val data = dbAccess.getGlucoseValues(minTime) + Log.i(LOG_ID, "sending ${data.size} values") + val gson = Gson() + val string = gson.toJson(data) + Log.v(LOG_ID, string) + outputStream.write(string.toByteArray()) + outputStream.flush() + outputStream.close() + channelClient.close(channel) + Log.d(LOG_ID, "db data sent") + if(GlucoDataService.appSource == AppSource.WEAR_APP) { + Log.i(LOG_ID, "Clear old data after sync") + dbAccess.deleteOldValues(System.currentTimeMillis()-Constants.DB_MAX_DATA_WEAR_TIME_MS) + } + } catch (exc: Exception) { + Log.e(LOG_ID, "sendData exception: " + exc.toString()) + } + }.start() + } + } catch (exc: Exception) { + Log.e(LOG_ID, "sendData exception: " + exc.toString()) + } + } + + private fun close(context: Context) { + try { + if(channel != null) { + Log.d(LOG_ID, "close channel") + val channelClient = Wearable.getChannelClient(context) + channelClient.close(channel!!) + channelClient.unregisterChannelCallback(this) + finished = true + channel = null + Log.d(LOG_ID, "channel closed") + } + } catch (exc: Exception) { + Log.e(LOG_ID, "close exception: " + exc.toString()) + } + } + + private fun waitFor(context: Context) { + Thread { + try { + Log.v(LOG_ID, "Waiting for receiving db data") + var count = 0 + while (!finished && count < 10) { + Thread.sleep(1000) + count++ + } + val success: Boolean + if (!finished) { + Log.w(LOG_ID, "Receiving still not finished!") + success = false + } else { + Log.d(LOG_ID, "Receiving finished!") + success = true + } + close(context) + if (success) { + Log.i(LOG_ID, "db sync succeeded") + } else { + Log.w(LOG_ID, "db sync failed") + if(retryCount < 3) { + retryCount++ + requestDbSync(context, retryCount) + } + } + } catch (exc: Exception) { + Log.e(LOG_ID, "waitFor exception: " + exc.message.toString() ) + } + }.start() + } + + fun requestDbSync(context: Context, curRetry: Int = 0) { + Log.d(LOG_ID, "request db sync - finished: $finished - retry: $curRetry") + if (finished) { + finished = false + retryCount = curRetry + registerChannel(context) + GlucoDataService.sendCommand(Command.DB_SYNC) + waitFor(context) + } + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/InternalNotifier.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/InternalNotifier.kt index e780e44fd..8a915c42e 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/InternalNotifier.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/InternalNotifier.kt @@ -39,10 +39,18 @@ object InternalNotifier { return notifiers.contains(notifier) } + private fun getNotifiers(): MutableMap?> { + Log.d(LOG_ID, "getNotifiers") + synchronized(notifiers) { + Log.d(LOG_ID, "getNotifiers synchronized") + return notifiers.toMutableMap() + } + } + fun notify(context: Context, notifySource: NotifySource, extras: Bundle?) { Log.d(LOG_ID, "Sending new data from " + notifySource.toString() + " to " + getNotifierCount(notifySource) + " notifier(s).") - val curNotifiers = notifiers.toMutableMap() + val curNotifiers = getNotifiers() curNotifiers.forEach{ try { if (it.value == null || it.value!!.contains(notifySource)) { @@ -57,8 +65,8 @@ object InternalNotifier { fun getNotifierCount(notifySource: NotifySource): Int { var count = 0 - val curNotifiers = notifiers.toMutableMap() - curNotifiers.forEach { + val curNotifiers = getNotifiers() + curNotifiers.forEach{ if (it.value == null || it.value!!.contains(notifySource)) { Log.v(LOG_ID, "Notifier " + it.toString() + " has source " + notifySource.toString()) count++ @@ -92,8 +100,12 @@ object InternalNotifier { private fun hasSource(notifier: NotifierInterface, notifySource: NotifySource): Boolean { if(hasNotifier(notifier)) { - Log.v(LOG_ID, "Check notifier ${notifier} has source $notifySource: ${notifiers[notifier]}") - return notifiers[notifier]!!.contains(notifySource) + synchronized(notifiers) { + if(notifiers.contains(notifier) && notifiers[notifier] != null) { + Log.v(LOG_ID, "Check notifier ${notifier} has source $notifySource: ${notifiers[notifier]}") + return notifiers[notifier]!!.contains(notifySource) + } + } } return false } diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/NotifySource.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/NotifySource.kt index 1790ec87a..ee49e1584 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/NotifySource.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/notifier/NotifySource.kt @@ -26,5 +26,7 @@ enum class NotifySource { COMMAND, NEW_VERSION_AVAILABLE, TTS_STATE_CHANGED, - DISPLAY_STATE_CHANGED; + DISPLAY_STATE_CHANGED, + GRAPH_CHANGED, + GRAPH_DATA_CHANGED; } \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/receiver/InternalActionReceiver.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/receiver/InternalActionReceiver.kt index ab74a91cb..2f104d519 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/receiver/InternalActionReceiver.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/receiver/InternalActionReceiver.kt @@ -9,6 +9,7 @@ import de.michelinside.glucodatahandler.common.Intents import de.michelinside.glucodatahandler.common.ReceiveData import de.michelinside.glucodatahandler.common.SourceState import de.michelinside.glucodatahandler.common.SourceStateData +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notifier.DataSource import de.michelinside.glucodatahandler.common.utils.GlucoDataUtils import de.michelinside.glucodatahandler.common.utils.TextToSpeechUtils @@ -22,6 +23,10 @@ class InternalActionReceiver: BroadcastReceiver() { try { Log.d(LOG_ID, "Action received: ${intent.action} - bundle: ${Utils.dumpBundle(intent.extras)}") when(intent.action) { + Intent.ACTION_DATE_CHANGED -> { + Log.i(LOG_ID, "Action: date changed received - trigger db cleanup") + dbAccess.cleanUpOldData() + } Constants.ACTION_FLOATING_WIDGET_TOGGLE -> { Log.d(LOG_ID, "Action: floating widget toggle") val sharedPref = context.getSharedPreferences(Constants.SHARED_PREF_TAG, Context.MODE_PRIVATE) diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DataSourceTask.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DataSourceTask.kt index 520de9439..029689554 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DataSourceTask.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DataSourceTask.kt @@ -13,6 +13,7 @@ import de.michelinside.glucodatahandler.common.R import de.michelinside.glucodatahandler.common.ReceiveData import de.michelinside.glucodatahandler.common.SourceState import de.michelinside.glucodatahandler.common.SourceStateData +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notifier.DataSource import de.michelinside.glucodatahandler.common.notifier.InternalNotifier import de.michelinside.glucodatahandler.common.notifier.NotifySource @@ -30,6 +31,7 @@ abstract class DataSourceTask(private val enabledKey: String, protected val sour private var httpRequest = HttpRequest() protected var retry = false protected var firstGetValue = false + private var isFirstRequest = true // first request after startup companion object { @@ -118,6 +120,7 @@ abstract class DataSourceTask(private val enabledKey: String, protected val sour } SourceState.CONNECTED -> { Log.i(LOG_ID, "Set connected for source $source") + isFirstRequest = false } else -> { Log.w(LOG_ID, "Set state for source $source: $state - $error ($code) - message: $message") @@ -395,4 +398,18 @@ abstract class DataSourceTask(private val enabledKey: String, protected val sour return result } } + + protected fun getFirstNeedGraphValueTime(): Long { + val firstLastPair = dbAccess.getFirstLastTimestamp() + val minTime = System.currentTimeMillis() - Constants.DB_MAX_DATA_WEAR_TIME_MS // 24h for init the first time + if(firstLastPair.first == 0L || (isFirstRequest && firstLastPair.first < minTime)) { + Log.i(LOG_ID, "First value is ${Utils.getElapsedTimeMinute(firstLastPair.first)} minutes old - try get older data on first request") + return minTime + } + if(firstLastPair.second > 0L && Utils.getElapsedTimeMinute(firstLastPair.second) > interval) { + Log.i(LOG_ID, "Last value is ${Utils.getElapsedTimeMinute(firstLastPair.second)} minutes old - try get newer data") + return maxOf(firstLastPair.second + 30000, minTime) + } + return 0L + } } diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DexcomShareSourceTask.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DexcomShareSourceTask.kt index af25bfb62..4797ac1ca 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DexcomShareSourceTask.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/DexcomShareSourceTask.kt @@ -10,10 +10,13 @@ import de.michelinside.glucodatahandler.common.GlucoDataService import de.michelinside.glucodatahandler.common.R import de.michelinside.glucodatahandler.common.ReceiveData import de.michelinside.glucodatahandler.common.SourceState +import de.michelinside.glucodatahandler.common.database.GlucoseValue +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notifier.DataSource import de.michelinside.glucodatahandler.common.notifier.InternalNotifier import de.michelinside.glucodatahandler.common.notifier.NotifySource import de.michelinside.glucodatahandler.common.utils.GlucoDataUtils +import de.michelinside.glucodatahandler.common.utils.Utils import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection @@ -77,7 +80,13 @@ class DexcomShareSourceTask : DataSourceTask(Constants.SHARED_PREF_DEXCOM_SHARE_ Log.v(LOG_ID, "getValue called") if(sessionId.isNullOrEmpty()) return false - val query = "sessionId=${sessionId}&minutes=30&maxCount=1" + var query = "sessionId=${sessionId}" + val firstValueTime = getFirstNeedGraphValueTime() + val count = if(firstValueTime > 0) Utils.getElapsedTimeMinute(firstValueTime) else 1 + if(count > 5) // Dexcom has 5 minute interval + query += "&minutes=${count}&maxCount=${count}" + else + query += "&minutes=30&maxCount=1" return handleValueResponse(httpPost(getUrl(DEXCOM_PATH_GET_VALUE, query), getHeader(false), "")) } @@ -178,7 +187,30 @@ class DexcomShareSourceTask : DataSourceTask(Constants.SHARED_PREF_DEXCOM_SHARE_ setState(SourceState.NO_NEW_VALUE, GlucoDataService.context!!.resources.getString(R.string.no_data_in_server_response)) return false } - val data = dataArray.getJSONObject(dataArray.length()-1) + var lastValueIndex = 0 + if(dataArray.length() > 1) { + val lastTime = 0L + Log.d(LOG_ID, "Handle ${dataArray.length()} entries in response as graph data - ${body.take(1000)}") + val values = mutableListOf() + for(i in 0 until dataArray.length()) { + try { + val data = dataArray.getJSONObject(i) + if(data.has("Value") && data.has("WT")) { + val value = data.getInt("Value") + val re = Regex("[^0-9]") + val worldTime = re.replace(data.getString("WT"), "").toLong() + values.add(GlucoseValue(worldTime, value)) + if(worldTime > lastTime) + lastValueIndex = i + } + } catch (exc: Exception) { + Log.e(LOG_ID, "Exception while parsing entry response: " + exc.message) + } + } + Log.i(LOG_ID, "Add ${values.size} values to database") + dbAccess.addGlucoseValues(values) + } + val data = dataArray.getJSONObject(lastValueIndex) if(data.has("Value") && data.has("Trend") && data.has("WT")) { val glucoExtras = Bundle() glucoExtras.putInt(ReceiveData.MGDL, data.getInt("Value")) diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/LibreLinkSourceTask.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/LibreLinkSourceTask.kt index b80894ca3..44af69441 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/LibreLinkSourceTask.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/LibreLinkSourceTask.kt @@ -9,7 +9,8 @@ import de.michelinside.glucodatahandler.common.Constants import de.michelinside.glucodatahandler.common.GlucoDataService import de.michelinside.glucodatahandler.common.R import de.michelinside.glucodatahandler.common.ReceiveData -import de.michelinside.glucodatahandler.common.SourceState +import de.michelinside.glucodatahandler.common.database.GlucoseValue +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notifier.DataSource import de.michelinside.glucodatahandler.common.notifier.InternalNotifier import de.michelinside.glucodatahandler.common.notifier.NotifySource @@ -369,10 +370,10 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, } override fun getValue(): Boolean { - if(patientId.isNotEmpty() && patientData.isNotEmpty()) { + if((patientId.isNotEmpty() && patientData.isNotEmpty()) || handleConnectionResponse(httpGet(getUrl(CONNECTION_ENDPOINT), getHeader()))) { return handleGraphResponse(httpGet(getUrl(GRAPH_ENDPOINT.format(patientId)), getHeader())) } - return handleGlucoseResponse(httpGet(getUrl(CONNECTION_ENDPOINT), getHeader())) + return false } private fun getRateFromTrend(trend: Int): Float { @@ -404,12 +405,18 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, val jsonObject = checkResponse(body) if (jsonObject != null && jsonObject.has("data")) { val data = jsonObject.optJSONObject("data") - if(data != null && data.has("connection")) { - val connection = data.optJSONObject("connection") - if(connection!=null) - return parseConnectionData(connection) + if(data != null) { + if(data.has("graphData")) { + parseGraphData(data.optJSONArray("graphData")) + } + if(data.has("connection")) { + val connection = data.optJSONObject("connection") + if(connection!=null) + return parseGlucoseData(connection) + } } } + Log.e(LOG_ID, "No data found in response: ${replaceSensitiveData(body!!)}") setLastError("Invalid response! Please send logs to developer.") reset() @@ -417,8 +424,36 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, return false } - private fun handleGlucoseResponse(body: String?): Boolean { - /* + private fun parseGraphData(graphData: JSONArray?) { + try { + if(graphData != null && graphData.length() > 0) { + Log.d(LOG_ID, "Parse graph data for ${graphData.length()} entries") + val firstLastPair = dbAccess.getFirstLastTimestamp() + if(Utils.getElapsedTimeMinute(firstLastPair.first) < (8*60) || Utils.getElapsedTimeMinute(firstLastPair.second) > (30)) { + val values = mutableListOf() + for (i in 0 until graphData.length()) { + val glucoseData = graphData.optJSONObject(i) + if(glucoseData != null) { + val parsedUtc = parseUtcTimestamp(glucoseData.optString("FactoryTimestamp")) + val parsedLocal = parseLocalTimestamp(glucoseData.optString("Timestamp")) + val time = if (parsedUtc > 0) parsedUtc else parsedLocal + if(time < firstLastPair.first || time > firstLastPair.second) { + val value = glucoseData.optInt("ValueInMgPerDl") + if(value > 0 && time > 0) + values.add(GlucoseValue(time, value)) + } + } + } + dbAccess.addGlucoseValues(values) + } + } + } catch (exc: Exception) { + Log.e(LOG_ID, "parseGraphData exception: " + exc.toString() ) + } + } + + private fun handleConnectionResponse(body: String?): Boolean { + /* used for getting patient id { "status": 0, "data": [ @@ -459,12 +494,7 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, } return false } - val data = getPatientData(array) - if (data == null) { - setState(SourceState.NO_NEW_VALUE, GlucoDataService.context!!.resources.getString(R.string.no_data_in_server_response)) - return false - } - return parseConnectionData(data) + return getPatientData(array) } else { Log.e(LOG_ID, "No data array found in response: ${replaceSensitiveData(body!!)}") setLastError("Invalid response! Please send logs to developer.") @@ -475,7 +505,7 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, return false } - private fun parseConnectionData(data: JSONObject): Boolean { + private fun parseGlucoseData(data: JSONObject): Boolean { if(data.has("glucoseMeasurement")) { val glucoseData = data.optJSONObject("glucoseMeasurement") if (glucoseData != null) { @@ -490,13 +520,6 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, glucoExtras.putFloat(ReceiveData.GLUCOSECUSTOM, glucoseData.optDouble("Value").toFloat()) glucoExtras.putInt(ReceiveData.MGDL, glucoseData.optInt("ValueInMgPerDl")) glucoExtras.putFloat(ReceiveData.RATE, getRateFromTrend(glucoseData.optInt("TrendArrow"))) - if (glucoseData.optBoolean("isHigh")) - glucoExtras.putInt(ReceiveData.ALARM, 6) - else if (glucoseData.optBoolean("isLow")) - glucoExtras.putInt(ReceiveData.ALARM, 7) - else - glucoExtras.putInt(ReceiveData.ALARM, 0) - glucoExtras.putString(ReceiveData.SERIAL, "LibreLink") if (data.has("sensor")) { val sensor = data.optJSONObject("sensor") @@ -513,7 +536,7 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, return false } - private fun getPatientData(dataArray: JSONArray): JSONObject? { + private fun getPatientData(dataArray: JSONArray): Boolean { if(dataArray.length() > patientData.size) { // create patientData map val checkPatienId = patientData.isEmpty() && patientId.isNotEmpty() @@ -544,16 +567,16 @@ class LibreLinkSourceTask : DataSourceTask(Constants.SHARED_PREF_LIBRE_ENABLED, for (i in 0 until dataArray.length()) { val data = dataArray.getJSONObject(i) if (data.has("patientId") && data.getString("patientId") == patientId) { - return data + return true } } - return null + return false } else if (patientData.isNotEmpty()) { patientId = patientData.keys.first() Log.d(LOG_ID, "Using patient ID $patientId") + return true } - // default: use first one - return dataArray.optJSONObject(0) + return false // no patient found } override fun interrupt() { diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/NightscoutSourceTask.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/NightscoutSourceTask.kt index 121f31062..765e21fd2 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/NightscoutSourceTask.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/tasks/NightscoutSourceTask.kt @@ -6,6 +6,8 @@ import android.os.Bundle import android.util.Log import de.michelinside.glucodatahandler.common.Constants import de.michelinside.glucodatahandler.common.ReceiveData +import de.michelinside.glucodatahandler.common.database.GlucoseValue +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notifier.DataSource import de.michelinside.glucodatahandler.common.utils.GlucoDataUtils import de.michelinside.glucodatahandler.common.utils.JsonUtils @@ -23,6 +25,7 @@ class NightscoutSourceTask: DataSourceTask(Constants.SHARED_PREF_NIGHTSCOUT_ENAB private var iob_cob_support = true const val PEBBLE_ENDPOINT = "/pebble" const val ENTRIES_ENDPOINT = "/api/v1/entries/current.json" + const val GRAPHDATA_ENDPOINT = "/api/v1/entries/sgv.json?find[date][\$gt]=%d&count=%d" } override fun hasIobCobSupport(): Boolean { @@ -43,9 +46,28 @@ class NightscoutSourceTask: DataSourceTask(Constants.SHARED_PREF_NIGHTSCOUT_ENAB return true } + private fun getGraphData(firstValueTime: Long): Boolean { + val count = Utils.getElapsedTimeMinute(firstValueTime) + Log.i(LOG_ID, "Getting up to $count graph data for time > ${Utils.getUiTimeStamp(firstValueTime)} - ($firstValueTime)") + if(count > 0) { + val (result, errorText) = handleEntriesResponse(httpGet(getUrl(GRAPHDATA_ENDPOINT.format(firstValueTime,count)), getHeader())) + if(errorText.isNotEmpty()) + Log.e(LOG_ID, "Error while getting graph data: $errorText") + return result + } + return false + } + override fun getValue() : Boolean { + val firstNeededValue = getFirstNeedGraphValueTime() + if (firstNeededValue > 0) { + if(getGraphData(firstNeededValue) && !iob_cob_support) { + // data received, also no iob/cob to request + return true + } + } if (!handlePebbleResponse(httpGet(getUrl(PEBBLE_ENDPOINT), getHeader()))) { - if (!hasIobCobSupport() || ReceiveData.getElapsedIobCobTimeMinute() > 0) { + if (!hasIobCobSupport() || ReceiveData.getElapsedTimeMinute() > 0) { // only check for new value, if there is no (otherwise it was only called for IOB/COB) val body = httpGet(getUrl(ENTRIES_ENDPOINT), getHeader()) if (body == null && lastErrorCode >= 300) @@ -96,8 +118,12 @@ class NightscoutSourceTask: DataSourceTask(Constants.SHARED_PREF_NIGHTSCOUT_ENAB private fun getUrl(endpoint: String): String { var resultUrl = url + endpoint - if (token.isNotEmpty()) - resultUrl += "?token=" + token + if (token.isNotEmpty()) { + if(resultUrl.contains("?")) + resultUrl += "&token=" + token + else + resultUrl += "?token=" + token + } return resultUrl } @@ -111,7 +137,7 @@ class NightscoutSourceTask: DataSourceTask(Constants.SHARED_PREF_NIGHTSCOUT_ENAB private fun handleEntriesResponse(body: String?) : Pair { if (!body.isNullOrEmpty()) { - Log.d(LOG_ID, "Handle entries response: " + body) + Log.d(LOG_ID, "Handle entries response: " + body.take(1000)) val jsonEntries = JSONArray(body) if (jsonEntries.length() <= 0) { return Pair(false, "No entries in body: " + body) @@ -120,11 +146,11 @@ class NightscoutSourceTask: DataSourceTask(Constants.SHARED_PREF_NIGHTSCOUT_ENAB val jsonObject = jsonEntries.getJSONObject(0) val type: String? = if (jsonObject.has("type") ) jsonObject.getString("type") else null if (type == null || type != "sgv") { - return Pair(false, "Unsupported type '" + type + "' found in response: " + body) + return Pair(false, "Unsupported type '" + type + "' found in response: " + body.take(100)) } if(!jsonObject.has("date") || !jsonObject.has("sgv") || !jsonObject.has("direction")) - return Pair(false, "Missing values in response: " + body) + return Pair(false, "Missing values in response: " + body.take(100)) val glucoExtras = Bundle() setSgv(glucoExtras, jsonObject) @@ -133,6 +159,26 @@ class NightscoutSourceTask: DataSourceTask(Constants.SHARED_PREF_NIGHTSCOUT_ENAB if(jsonObject.has("device")) glucoExtras.putString(ReceiveData.SERIAL, jsonObject.getString("device")) + try { + if(jsonEntries.length() > 1) { + val values = mutableListOf() + for (i in 0 until jsonEntries.length()) { + val jsonEntry = jsonEntries.getJSONObject(i) + var glucose = JsonUtils.getFloat("sgv", jsonEntry) + if (GlucoDataUtils.isMmolValue(glucose)) + glucose = GlucoDataUtils.mmolToMg(glucose) + val time = jsonEntry.getLong("date") + if(!glucose.isNaN() && time > 0) { + values.add(GlucoseValue(time, glucose.toInt())) + } + } + Log.i(LOG_ID, "Add ${values.size} values to database") + dbAccess.addGlucoseValues(values) + } + } catch (exc: Exception) { + Log.e(LOG_ID, "Exception while parsing entries response: " + exc.message) + } + handleResult(glucoExtras) return Pair(true, "") } diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/utils/DummyGraphData.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/utils/DummyGraphData.kt new file mode 100644 index 000000000..2f2ab75cb --- /dev/null +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/utils/DummyGraphData.kt @@ -0,0 +1,24 @@ +package de.michelinside.glucodatahandler.common.utils + +import kotlin.random.Random + +object DummyGraphData { + fun create(hours: Int = 8, stepMinute: Int = 5, min: Int = 50, max: Int = 250): Map { + val data = mutableMapOf() + if(hours<=0) + return data + val startTime = System.currentTimeMillis() - hours * 60 * 60 * 1000 + val timeDiff = 60L * 1000L * stepMinute + var lastValue = min + var deltaFactor = 1 + for (i in startTime until System.currentTimeMillis() step timeDiff) { + if(deltaFactor > 0 && lastValue >= max) + deltaFactor = -1 + else if(deltaFactor < 0 && lastValue <= min) + deltaFactor = 1 + lastValue += Random.nextInt(10) * deltaFactor + data[i] = lastValue + } + return data + } +} \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/utils/PackageUtils.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/utils/PackageUtils.kt index 4b02a5405..d03a769a4 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/utils/PackageUtils.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/utils/PackageUtils.kt @@ -1,11 +1,17 @@ package de.michelinside.glucodatahandler.common.utils +import android.annotation.SuppressLint import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ResolveInfo +import android.os.Build import android.util.Log +import com.google.android.gms.wearable.WearableListenerService.RECEIVER_EXPORTED +import com.google.android.gms.wearable.WearableListenerService.RECEIVER_VISIBLE_TO_INSTANT_APPS import de.michelinside.glucodatahandler.common.Constants import de.michelinside.glucodatahandler.common.receiver.InternalActionReceiver import kotlinx.coroutines.* @@ -87,7 +93,7 @@ object PackageUtils { fun getTapAction( context: Context, tapAction: String? - ): Pair { // Boolean: true = Broadcase - false = Activity + ): Pair { // Boolean: true = Broadcast - false = Activity Log.d(LOG_ID, "Get tap action $tapAction") if (tapAction == null && context.packageName != null) { return getTapAction(context, context.packageName) @@ -184,4 +190,19 @@ object PackageUtils { } return gdaAvailable } + + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + fun registerReceiver(context: Context, receiver: BroadcastReceiver, filter: IntentFilter) { + Log.d(LOG_ID, "Register receiver ${receiver.javaClass.name} on ${context.applicationContext}") + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.applicationContext.registerReceiver(receiver, filter, RECEIVER_EXPORTED or RECEIVER_VISIBLE_TO_INSTANT_APPS) + } else { + context.applicationContext.registerReceiver(receiver, filter) + } + } catch (exc: Exception) { + Log.e(LOG_ID, "registerReceiver exception: " + exc.toString()) + } + } } \ No newline at end of file diff --git a/common/src/main/java/de/michelinside/glucodatahandler/common/utils/Utils.kt b/common/src/main/java/de/michelinside/glucodatahandler/common/utils/Utils.kt index 2b2532896..36507ae8e 100644 --- a/common/src/main/java/de/michelinside/glucodatahandler/common/utils/Utils.kt +++ b/common/src/main/java/de/michelinside/glucodatahandler/common/utils/Utils.kt @@ -452,6 +452,10 @@ object Utils { return round((System.currentTimeMillis()-time).toFloat()/60000, 0, roundingMode).toLong() } + fun getTimeDiffMinute(time1: Long, time2: Long, roundingMode: RoundingMode = RoundingMode.DOWN): Long { + return round((time1-time2).toFloat()/60000, 0, roundingMode).toLong() + } + fun getUiTimeStamp(time: Long): String { if(getElapsedTimeMinute(time) >= (60*24)) return DateFormat.getDateTimeInstance().format(Date(time)) diff --git a/common/src/main/res/drawable-nodpi/marker.png b/common/src/main/res/drawable-nodpi/marker.png new file mode 100644 index 000000000..580542829 Binary files /dev/null and b/common/src/main/res/drawable-nodpi/marker.png differ diff --git a/common/src/main/res/layout/custom_marker_view_layout.xml b/common/src/main/res/layout/custom_marker_view_layout.xml new file mode 100644 index 000000000..72f687172 --- /dev/null +++ b/common/src/main/res/layout/custom_marker_view_layout.xml @@ -0,0 +1,39 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/marker_layout.xml b/common/src/main/res/layout/marker_layout.xml new file mode 100644 index 000000000..fd17bbb22 --- /dev/null +++ b/common/src/main/res/layout/marker_layout.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/values-night/colors.xml b/common/src/main/res/values-night/colors.xml similarity index 78% rename from mobile/src/main/res/values-night/colors.xml rename to common/src/main/res/values-night/colors.xml index 8b6022102..dee375197 100644 --- a/mobile/src/main/res/values-night/colors.xml +++ b/common/src/main/res/values-night/colors.xml @@ -7,6 +7,7 @@ #FF018786 #FF000000 #FFFFFFFF + #FF005CCC #FFFF0000 #FFFFFF00 #FF00FF00 @@ -16,4 +17,7 @@ #FF666666 #55000000 @color/white + #BB000000 + @color/white + #FFCCCCCC \ No newline at end of file diff --git a/mobile/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml similarity index 79% rename from mobile/src/main/res/values/colors.xml rename to common/src/main/res/values/colors.xml index cf85e9a46..7f99a6161 100644 --- a/mobile/src/main/res/values/colors.xml +++ b/common/src/main/res/values/colors.xml @@ -5,6 +5,7 @@ #FF3700B3 #FF03DAC5 #FF018786 + #FF005CCC #FF000000 #FFFFFFFF #FFFF0000 @@ -17,4 +18,7 @@ #FFF0F0F0 #55000000 @color/white + #77000000 + @color/black + #FF444444 \ No newline at end of file diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index f1ab78457..1c348619c 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -770,5 +770,14 @@ Choose up to 3 snooze durations to display as buttons on the alarm notification. Show IOB/COB values Show IOB and COB values, if available. + Graph + Graph duration + Number of hours shown in the graph in complication and on main screen. + Number of hours shown as default in the graph on main screen. + Show graph + Database + Reset database + Press here to remove all data from database. + If you press OK, all data from database will be removed!\nAre you sure? diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dddffa93c..1869f4165 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Dec 14 15:11:00 CET 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/mobile/build.gradle b/mobile/build.gradle index 1e0c8c474..d7d89d6f1 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -94,6 +94,7 @@ dependencies { implementation "com.ncorti:slidetoact:0.11.0" implementation 'com.takisoft.preferencex:preferencex-datetimepicker:1.1.0' implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' + implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/mobile/src/main/java/de/michelinside/glucodatahandler/MainActivity.kt b/mobile/src/main/java/de/michelinside/glucodatahandler/MainActivity.kt index c8925cd4c..c943fafb1 100644 --- a/mobile/src/main/java/de/michelinside/glucodatahandler/MainActivity.kt +++ b/mobile/src/main/java/de/michelinside/glucodatahandler/MainActivity.kt @@ -36,6 +36,8 @@ import de.michelinside.glucodatahandler.common.ReceiveData import de.michelinside.glucodatahandler.common.SourceState import de.michelinside.glucodatahandler.common.SourceStateData import de.michelinside.glucodatahandler.common.WearPhoneConnection +import de.michelinside.glucodatahandler.common.chart.ChartCreator +import de.michelinside.glucodatahandler.common.chart.GlucoseChart import de.michelinside.glucodatahandler.common.notification.AlarmHandler import de.michelinside.glucodatahandler.common.notification.AlarmState import de.michelinside.glucodatahandler.common.notification.AlarmType @@ -75,12 +77,14 @@ class MainActivity : AppCompatActivity(), NotifierInterface { private lateinit var btnSources: Button private lateinit var sharedPref: SharedPreferences private lateinit var optionsMenu: Menu + private lateinit var chart: GlucoseChart private var alarmIcon: MenuItem? = null private var snoozeMenu: MenuItem? = null private var floatingWidgetItem: MenuItem? = null private val LOG_ID = "GDH.Main" private var requestNotificationPermission = false private var doNotUpdate = false + private lateinit var chartCreator: ChartCreator override fun onCreate(savedInstanceState: Bundle?) { try { @@ -103,6 +107,7 @@ class MainActivity : AppCompatActivity(), NotifierInterface { tableAlarms = findViewById(R.id.tableAlarms) tableDetails = findViewById(R.id.tableDetails) tableNotes = findViewById(R.id.tableNotes) + chart = findViewById(R.id.chart) PreferenceManager.setDefaultValues(this, R.xml.preferences, false) sharedPref = this.getSharedPreferences(Constants.SHARED_PREF_TAG, Context.MODE_PRIVATE) @@ -126,6 +131,8 @@ class MainActivity : AppCompatActivity(), NotifierInterface { if (requestPermission()) GlucoDataServiceMobile.start(this) TextToSpeechUtils.initTextToSpeech(this) + chartCreator = ChartCreator(chart, this, Constants.SHARED_PREF_GRAPH_DURATION_PHONE_MAIN) + chartCreator.create() } catch (exc: Exception) { Log.e(LOG_ID, "onCreate exception: " + exc.message.toString() ) } @@ -149,6 +156,7 @@ class MainActivity : AppCompatActivity(), NotifierInterface { checkUncaughtException() doNotUpdate = false update() + chartCreator.resume() InternalNotifier.addNotifier(this, this, mutableSetOf( NotifySource.BROADCAST, NotifySource.IOB_COB_CHANGE, @@ -174,6 +182,13 @@ class MainActivity : AppCompatActivity(), NotifierInterface { Log.e(LOG_ID, "onResume exception: " + exc.message.toString() ) } } + + override fun onDestroy() { + Log.v(LOG_ID, "onDestroy called") + super.onDestroy() + chartCreator.close() + } + private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() @@ -207,17 +222,15 @@ class MainActivity : AppCompatActivity(), NotifierInterface { .setTitle(CR.string.request_exact_alarm_title) .setMessage(CR.string.request_exact_alarm_summary) .setPositiveButton(CR.string.button_ok) { dialog, which -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - try { - startActivity( - Intent( - ACTION_REQUEST_SCHEDULE_EXACT_ALARM, - Uri.parse("package:$packageName") - ) + try { + startActivity( + Intent( + ACTION_REQUEST_SCHEDULE_EXACT_ALARM, + Uri.parse("package:$packageName") ) - } catch (exc: Exception) { - Log.e(LOG_ID, "requestExactAlarmPermission exception: " + exc.message.toString() ) - } + ) + } catch (exc: Exception) { + Log.e(LOG_ID, "requestExactAlarmPermission exception: " + exc.message.toString() ) } } .setNegativeButton(CR.string.button_cancel) { dialog, which -> @@ -539,7 +552,7 @@ class MainActivity : AppCompatActivity(), NotifierInterface { } else { btnSources.visibility = View.GONE } - + //chartHandler.update() updateNotesTable() updateAlarmsTable() updateConnectionsTable() diff --git a/mobile/src/main/java/de/michelinside/glucodatahandler/preferences/ExportImportSettingsFragment.kt b/mobile/src/main/java/de/michelinside/glucodatahandler/preferences/ExportImportSettingsFragment.kt index 14c969b48..3a32581ad 100644 --- a/mobile/src/main/java/de/michelinside/glucodatahandler/preferences/ExportImportSettingsFragment.kt +++ b/mobile/src/main/java/de/michelinside/glucodatahandler/preferences/ExportImportSettingsFragment.kt @@ -11,12 +11,15 @@ import android.provider.DocumentsContract import android.util.Log import androidx.preference.Preference import de.michelinside.glucodatahandler.R +import de.michelinside.glucodatahandler.common.R as CR import de.michelinside.glucodatahandler.common.AppSource import de.michelinside.glucodatahandler.common.Constants import de.michelinside.glucodatahandler.common.WearPhoneConnection +import de.michelinside.glucodatahandler.common.database.dbAccess import de.michelinside.glucodatahandler.common.notifier.InternalNotifier import de.michelinside.glucodatahandler.common.notifier.NotifierInterface import de.michelinside.glucodatahandler.common.notifier.NotifySource +import de.michelinside.glucodatahandler.common.ui.Dialogs import de.michelinside.glucodatahandler.common.utils.Utils import de.michelinside.glucodatahandler.watch.LogcatReceiver import java.text.SimpleDateFormat @@ -85,6 +88,17 @@ class ExportImportSettingsFragment: SettingsFragmentBase(R.xml.pref_export_impor SaveLogs(AppSource.WEAR_APP, downloadUri) true } + + val prefResetDb = findPreference(Constants.SHARED_PREF_RESET_DATABASE) + prefResetDb!!.setOnPreferenceClickListener { + Dialogs.showOkCancelDialog(requireContext(), resources.getString(CR.string.reset_db), resources.getString(CR.string.reset_db_warning), + { _, _ -> + dbAccess.deleteAllValues() + } + ) + true + } + } catch (exc: Exception) { Log.e(LOG_ID, "initPreferences exception: " + exc.message.toString() ) } diff --git a/mobile/src/main/res/layout-land/activity_main.xml b/mobile/src/main/res/layout-land/activity_main.xml index f9cbf0bd4..776e76c69 100644 --- a/mobile/src/main/res/layout-land/activity_main.xml +++ b/mobile/src/main/res/layout-land/activity_main.xml @@ -12,28 +12,24 @@ android:layout_height="match_parent" tools:ignore="UselessLeaf" /> - + tools:ignore="NestedWeights,UselessParent"> - - -