1
0
mirror of https://github.com/dzeiocom/OpenHealth.git synced 2025-04-24 03:42:13 +00:00
This commit is contained in:
Florian Bouillon 2021-12-10 16:43:11 +01:00
parent a164e23b2d
commit 57beb426fb
29 changed files with 875 additions and 121 deletions

123
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

1
.idea/gradle.xml generated
View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>

4
.idea/misc.xml generated
View File

@ -3,6 +3,10 @@
<component name="DesignSurface"> <component name="DesignSurface">
<option name="filePathToZoomLevelMap"> <option name="filePathToZoomLevelMap">
<map> <map>
<entry key="..\:/Git/Dzeio/OpenHealth/app/src/main/res/layout/fragment_home.xml" value="0.13489583333333333" />
<entry key="..\:/Git/Dzeio/OpenHealth/app/src/main/res/layout/fragment_import.xml" value="0.1" />
<entry key="..\:/Git/Dzeio/OpenHealth/app/src/main/res/menu/activity_main_drawer.xml" value="0.10989583333333333" />
<entry key="..\:/Git/Dzeio/OpenHealth/app/src/main/res/menu/main.xml" value="0.1875" />
<entry key="..\:/git/Dzeio/OpenHealth/app/src/main/res/layout/content_main.xml" value="0.5135869565217391" /> <entry key="..\:/git/Dzeio/OpenHealth/app/src/main/res/layout/content_main.xml" value="0.5135869565217391" />
<entry key="..\:/git/Dzeio/OpenHealth/app/src/main/res/layout/fragment_home.xml" value="0.5135869565217391" /> <entry key="..\:/git/Dzeio/OpenHealth/app/src/main/res/layout/fragment_home.xml" value="0.5135869565217391" />
</map> </map>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,6 +1,7 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
} }
android { android {
@ -51,4 +52,30 @@ dependencies {
// Google Fit // Google Fit
implementation "com.google.android.gms:play-services-fitness:21.0.0" implementation "com.google.android.gms:play-services-fitness:21.0.0"
implementation "com.google.android.gms:play-services-auth:20.0.0" implementation "com.google.android.gms:play-services-auth:20.0.0"
// Samsung Health
implementation files('libs/samsung-health-data-1.5.0.aar')
// ROOM
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// optional - RxJava2 support for Room
implementation "androidx.room:room-rxjava2:$room_version"
// optional - RxJava3 support for Room
implementation "androidx.room:room-rxjava3:$room_version"
// optional - Guava support for Room, including Optional and ListenableFuture
implementation "androidx.room:room-guava:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
// optional - Paging 3 Integration
implementation "androidx.room:room-paging:2.4.0-rc01"
} }

View File

@ -6,6 +6,11 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION " /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION " />
<!-- Samsung Health-->
<queries>
<package android:name="com.sec.android.app.shealth" />
</queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -13,6 +18,12 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OpenHealth"> android:theme="@style/Theme.OpenHealth">
<!-- Samsung Health-->
<meta-data
android:name="com.samsung.android.health.permission.read"
android:value="com.samsung.health.step_count;com.samsung.shealth.step_daily_trend" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -12,6 +12,8 @@ import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.dzeio.openhealth.databinding.ActivityMainBinding import com.dzeio.openhealth.databinding.ActivityMainBinding
import com.dzeio.openhealth.db.AppDatabase
import com.dzeio.openhealth.db.entities.Weight
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -39,12 +41,11 @@ class MainActivity : AppCompatActivity() {
// menu should be considered as top level destinations. // menu should be considered as top level destinations.
appBarConfiguration = AppBarConfiguration( appBarConfiguration = AppBarConfiguration(
setOf( setOf(
R.id.nav_home, R.id.nav_gallery, R.id.nav_slideshow R.id.nav_home, R.id.nav_gallery, R.id.nav_import
), drawerLayout ), drawerLayout
) )
setupActionBarWithNavController(navController, appBarConfiguration) setupActionBarWithNavController(navController, appBarConfiguration)
navView.setupWithNavController(navController) navView.setupWithNavController(navController)
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {

View File

@ -7,6 +7,8 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import com.dzeio.openhealth.db.AppDatabase
import com.dzeio.openhealth.db.entities.Weight
import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.fitness.Fitness import com.google.android.gms.fitness.Fitness
import com.google.android.gms.fitness.FitnessOptions import com.google.android.gms.fitness.FitnessOptions
@ -18,9 +20,10 @@ import com.google.android.gms.fitness.request.DataReadRequest
import com.google.android.gms.fitness.request.DataSourcesRequest import com.google.android.gms.fitness.request.DataSourcesRequest
import com.google.android.gms.fitness.request.OnDataPointListener import com.google.android.gms.fitness.request.OnDataPointListener
import com.google.android.gms.fitness.request.SensorRequest import com.google.android.gms.fitness.request.SensorRequest
import java.time.Instant import java.text.DateFormat
import java.time.LocalDateTime import java.text.SimpleDateFormat
import java.time.ZoneId import java.time.*
import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
enum class ActionRequestCode { enum class ActionRequestCode {
@ -194,49 +197,115 @@ class GoogleFit(
fun getHistory() { fun getHistory() {
val endTime = LocalDateTime.now().atZone(ZoneId.systemDefault()) val endTime = LocalDateTime.now().atZone(ZoneId.systemDefault())
val startTime = LocalDateTime.MIN.atZone(ZoneId.systemDefault())
val readRequest = DataReadRequest.Builder() val readRequest = DataReadRequest.Builder()
.aggregate(DataType.AGGREGATE_CALORIES_EXPENDED) .aggregate(DataType.AGGREGATE_CALORIES_EXPENDED)
.bucketByActivityType(1, TimeUnit.SECONDS) .bucketByActivityType(1, TimeUnit.SECONDS)
.setTimeRange(endTime.minusWeeks(1).toEpochSecond(), endTime.toEpochSecond(), TimeUnit.SECONDS) .setTimeRange(startTime.toEpochSecond(), endTime.toEpochSecond(), TimeUnit.SECONDS)
.build() .build()
Fitness.getHistoryClient(activity, GoogleSignIn.getAccountForExtension(activity, fitnessOptions)) Fitness.getHistoryClient(activity, GoogleSignIn.getAccountForExtension(activity, fitnessOptions))
.readData(readRequest) .readData(readRequest)
.addOnSuccessListener { response -> .addOnSuccessListener { response ->
// The aggregate query puts datasets into buckets, so flatten into a
// single list of datasets
for (dataSet in response.buckets.flatMap { it.dataSets }) { for (dataSet in response.buckets.flatMap { it.dataSets }) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
dumpDataSet(dataSet) dumpDataSet(dataSet)
} else {
Log.e(TAG, "DUMB SHIT")
} }
} }
.addOnFailureListener { e ->
Log.w(TAG,"There was an error reading data from Google Fit", e)
}
} }
// @RequiresApi(Build.VERSION_CODES.O)
// fun importStepCount() {
//
// runRequest(queryFitnessData())
//
// }
fun importWeight(callback : () -> Unit) {
val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val now = Date()
calendar.time = now
val endTime = calendar.timeInMillis
calendar.set(Calendar.YEAR, 2013) // Set year to 2013 to be sure to get data from when Google Fit Started to today
val startTime = calendar.timeInMillis
val dateFormat = DateFormat.getDateInstance()
Log.i(TAG, "Range Start: ${dateFormat.format(startTime)}")
Log.i(TAG, "Range End: ${dateFormat.format(endTime)}")
runRequest(DataReadRequest.Builder()
// The data request can specify multiple data types to return, effectively
// combining multiple data queries into one call.
// In this example, it's very unlikely that the request is for several hundred
// datapoints each consisting of a few steps and a timestamp. The more likely
// scenario is wanting to see how many steps were walked per day, for 7 days.
// .aggregate()
.read(DataType.TYPE_WEIGHT)
// Analogous to a "Group By" in SQL, defines how data should be aggregated.
// bucketByTime allows for a time span, whereas bucketBySession would allow
// bucketing by "sessions", which would need to be defined in code.
// .bucketByTime(1, TimeUnit.MINUTES)
// .bucketByActivityType(1, TimeUnit.SECONDS)
// .bucketBySession()
.setTimeRange(startTime, endTime, TimeUnit.MILLISECONDS)
.build(), callback)
} }
@RequiresApi(Build.VERSION_CODES.O) private fun runRequest(request: DataReadRequest, callback: () -> Unit) {
fun dumpDataSet(dataSet: DataSet) { Fitness.getHistoryClient(activity, GoogleSignIn.getAccountForExtension(activity, fitnessOptions))
Log.i(TAG, "Data returned for Data type: ${dataSet.dataType.name}") .readData(request)
.addOnSuccessListener { response ->
// The aggregate query puts datasets into buckets, so flatten into a
// single list of datasets
Log.d(TAG, "Received response! ${response.dataSets.size} ${response.buckets.size}")
for (dataSet in response.dataSets) {
dumpDataSet(dataSet)
}
for (dataSet in response.buckets.flatMap { it.dataSets }) {
dumpDataSet(dataSet)
}
callback.invoke()
}
.addOnFailureListener { e ->
Log.w(TAG,"There was an error reading data from Google Fit", e)
callback.invoke()
}
}
private fun dumpDataSet(dataSet: DataSet) {
Log.i(TAG, "Data returned for Data type: ${dataSet.dataType.name} ${dataSet.dataPoints.size}")
for (dp in dataSet.dataPoints) { for (dp in dataSet.dataPoints) {
val weight = Weight()
Log.i(TAG,"Data point:") Log.i(TAG,"Data point:")
Log.i(TAG,"\tType: ${dp.dataType.name}") Log.i(TAG,"\tType: ${dp.dataType.name}")
Log.i(TAG,"\tStart: ${dp.getStartTimeString()}") Log.i(TAG,"\tStart: ${dp.getStartTimeString()}")
Log.i(TAG,"\tEnd: ${dp.getEndTimeString()}") Log.i(TAG,"\tEnd: ${dp.getEndTimeString()}")
weight.timestamp = dp.getStartTime(TimeUnit.SECONDS)
weight.source = "GoogleFit"
for (field in dp.dataType.fields) { for (field in dp.dataType.fields) {
weight.weight = dp.getValue(field).asFloat()
Log.i(TAG,"\tField: ${field.name.toString()} Value: ${dp.getValue(field)}") Log.i(TAG,"\tField: ${field.name.toString()} Value: ${dp.getValue(field)}")
} }
AppDatabase.getInstance(activity).weightDao().insert(weight)
} }
} }
@RequiresApi(Build.VERSION_CODES.O) fun DataPoint.getStartTimeString(): String =
fun DataPoint.getStartTimeString() = Instant.ofEpochSecond(this.getStartTime(TimeUnit.SECONDS)) SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSSSSS", Locale.FRANCE)
.atZone(ZoneId.systemDefault()) .format(Date(this.getStartTime(TimeUnit.SECONDS) * 1000L))
.toLocalDateTime().toString()
@RequiresApi(Build.VERSION_CODES.O) fun DataPoint.getEndTimeString(): String =
fun DataPoint.getEndTimeString() = Instant.ofEpochSecond(this.getEndTime(TimeUnit.SECONDS)) SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSSSSS", Locale.FRANCE)
.atZone(ZoneId.systemDefault()) .format(Date(this.getEndTime(TimeUnit.SECONDS) * 1000L))
.toLocalDateTime().toString()
/** Unregisters the listener with the Sensors API. */ /** Unregisters the listener with the Sensors API. */
@ -261,4 +330,36 @@ class GoogleFit(
} }
// [END unregister_data_listener] // [END unregister_data_listener]
} }
/** Returns a [DataReadRequest] for all step count changes in the past week. */
private fun queryFitnessData(): DataReadRequest {
// [START build_read_data_request]
// Setting a start and end date using a range of 1 week before this moment.
val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val now = Date()
calendar.time = now
val endTime = calendar.timeInMillis
calendar.add(Calendar.YEAR, -1)
val startTime = calendar.timeInMillis
val dateFormat = DateFormat.getDateInstance()
Log.i(TAG, "Range Start: ${dateFormat.format(startTime)}")
Log.i(TAG, "Range End: ${dateFormat.format(endTime)}")
return DataReadRequest.Builder()
// The data request can specify multiple data types to return, effectively
// combining multiple data queries into one call.
// In this example, it's very unlikely that the request is for several hundred
// datapoints each consisting of a few steps and a timestamp. The more likely
// scenario is wanting to see how many steps were walked per day, for 7 days.
.aggregate(DataType.TYPE_STEP_COUNT_DELTA)
// Analogous to a "Group By" in SQL, defines how data should be aggregated.
// bucketByTime allows for a time span, whereas bucketBySession would allow
// bucketing by "sessions", which would need to be defined in code.
.bucketByTime(1, TimeUnit.SECONDS)
// .bucketBySession()
.setTimeRange(startTime, endTime, TimeUnit.MILLISECONDS)
.build()
}
} }

View File

@ -0,0 +1,143 @@
package com.dzeio.openhealth.connectors.samsunghealth
import android.app.Activity
import android.app.AlertDialog
import android.util.Log
import com.samsung.android.sdk.healthdata.*
class SamsungHealth(
private val activity: Activity
) {
private var currentStartTime: Long = 0
private var isFinishing = false
init {
currentStartTime = StepCountReader.TODAY_START_UTC_TIME
}
companion object {
const val TAG = "SamsungHealth"
}
// private val binningListAdapter: StepBinningData by lazy { StepBinningData() }
private val healthDataStore: HealthDataStore by lazy { HealthDataStore(activity, connectionListener) }
private val stepCountReader: StepCountReader by lazy { StepCountReader(healthDataStore, stepCountObserver) }
private val stepCountObserver: StepCountObserver = object : StepCountObserver {
override fun onChanged(count: Int) {
Log.d(TAG, "$count")
}
override fun onBinningDataChanged(binningCountList: List<StepBinningData>) {
Log.d(TAG, "${binningCountList.size}")
}
}
private val connectionListener: HealthDataStore.ConnectionListener = object : HealthDataStore.ConnectionListener {
override fun onConnected() {
Log.d(TAG, "onConnected")
if (checkPermissionsAcquired()) {
stepCountReader.requestDailyStepCount(currentStartTime)
} else {
requestPermission()
}
}
override fun onConnectionFailed(error: HealthConnectionErrorResult) {
Log.d(TAG, "onConnectionFailed")
showConnectionFailureDialog(error)
}
override fun onDisconnected() {
Log.d(TAG, "onDisconnected")
if (!isFinishing) {
healthDataStore.connectService()
}
}
}
private fun showConnectionFailureDialog(error: HealthConnectionErrorResult) {
if (isFinishing) {
return
}
val alert = AlertDialog.Builder(activity)
if (error.hasResolution()) {
when (error.errorCode) {
HealthConnectionErrorResult.PLATFORM_NOT_INSTALLED -> alert.setMessage("R.string.msg_req_install")
HealthConnectionErrorResult.OLD_VERSION_PLATFORM -> alert.setMessage("R.string.msg_req_upgrade")
HealthConnectionErrorResult.PLATFORM_DISABLED -> alert.setMessage("R.string.msg_req_enable")
HealthConnectionErrorResult.USER_AGREEMENT_NEEDED -> alert.setMessage("R.string.msg_req_agree")
else -> alert.setMessage("R.string.msg_req_available")
}
} else {
alert.setMessage("R.string.msg_conn_not_available")
}
alert.setPositiveButton("R.string.ok") { _, _ ->
if (error.hasResolution()) {
error.resolve(activity)
}
}
if (error.hasResolution()) {
alert.setNegativeButton("R.string.cancel", null)
}
alert.show()
}
// Check whether the permissions that this application needs are acquired
private fun checkPermissionsAcquired(): Boolean {
val pmsManager = HealthPermissionManager(healthDataStore)
// Check whether the permissions that this application needs are acquired
return runCatching { pmsManager.isPermissionAcquired(permissionKeySet) }
.onFailure { Log.e(TAG, "Permission request fails.", it) }
.map { it.values.all { it } }
.getOrDefault(false)
}
private fun requestPermission() {
val pmsManager = HealthPermissionManager(healthDataStore)
// Show user permission UI for allowing user to change options
runCatching { pmsManager.requestPermissions(permissionKeySet, activity) }
.onFailure { Log.e(TAG, "Permission setting fails.", it) }
.getOrNull()
?.setResultListener(mPermissionListener)
}
private val permissionKeySet: Set<HealthPermissionManager.PermissionKey> =
setOf(
HealthPermissionManager.PermissionKey(HealthConstants.StepCount.HEALTH_DATA_TYPE, HealthPermissionManager.PermissionType.READ),
HealthPermissionManager.PermissionKey(
HealthConstants.StepDailyTrend.HEALTH_DATA_TYPE,
HealthPermissionManager.PermissionType.READ
)
)
private val mPermissionListener = HealthResultHolder.ResultListener<HealthPermissionManager.PermissionResult> { result ->
// Show a permission alarm and clear step count if permissions are not acquired
if (result.resultMap.values.any { !it }) {
// List is now empty
showPermissionAlarmDialog()
} else {
// Get the daily step count of a particular day and display it
stepCountReader.requestDailyStepCount(currentStartTime)
}
}
private fun showPermissionAlarmDialog() {
if (isFinishing) {
return
}
AlertDialog.Builder(activity)
.setTitle("R.string.notice")
.setMessage("R.string.msg_perm_acquired")
.setPositiveButton("R.string.ok", null)
.show()
}
fun importStepCount() {
healthDataStore.connectService()
}
}

View File

@ -0,0 +1,3 @@
package com.dzeio.openhealth.connectors.samsunghealth
data class StepBinningData(var time: String, val count: Int)

View File

@ -0,0 +1,8 @@
package com.dzeio.openhealth.connectors.samsunghealth
interface StepCountObserver {
fun onChanged(count: Int)
fun onBinningDataChanged(binningCountList: List<StepBinningData>)
}

View File

@ -0,0 +1,141 @@
package com.dzeio.openhealth.connectors.samsunghealth
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.samsung.android.sdk.healthdata.HealthConstants.StepCount
import com.samsung.android.sdk.healthdata.HealthConstants.StepDailyTrend
import com.samsung.android.sdk.healthdata.HealthDataResolver
import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest
import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest.AggregateFunction
import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest.TimeGroupUnit
import com.samsung.android.sdk.healthdata.HealthDataResolver.Filter
import com.samsung.android.sdk.healthdata.HealthDataResolver.ReadRequest
import com.samsung.android.sdk.healthdata.HealthDataResolver.SortOrder
import com.samsung.android.sdk.healthdata.HealthDataStore
import com.samsung.android.sdk.healthdata.HealthDataUtil
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
class StepCountReader(
store: HealthDataStore,
private val observer: StepCountObserver
) {
private val healthDataResolver: HealthDataResolver = HealthDataResolver(store, Handler(Looper.getMainLooper()))
// Get the daily total step count of a specified day
fun requestDailyStepCount(startTime: Long) {
if (startTime >= TODAY_START_UTC_TIME) {
// Get today step count
readStepCount(startTime)
} else {
// Get historical step count
readStepDailyTrend(startTime)
}
}
private fun readStepCount(startTime: Long) {
// Get sum of step counts by device
val request = AggregateRequest.Builder()
.setDataType(StepCount.HEALTH_DATA_TYPE)
.addFunction(AggregateFunction.SUM, StepCount.COUNT, ALIAS_TOTAL_COUNT)
.addGroup(StepCount.DEVICE_UUID, ALIAS_DEVICE_UUID)
.setLocalTimeRange(StepCount.START_TIME, StepCount.TIME_OFFSET, startTime, startTime + TIME_INTERVAL)
.setSort(ALIAS_TOTAL_COUNT, SortOrder.DESC)
.build()
runCatching { healthDataResolver.aggregate(request) }
.onFailure { Log.e(TAG, "Getting step count fails.", it) }
.getOrNull()
?.setResultListener {
it.use {
it.firstOrNull()
.also { observer.onChanged(it?.getInt(ALIAS_TOTAL_COUNT) ?: 0) }
?.let { readStepCountBinning(startTime, it.getString(ALIAS_DEVICE_UUID)) }
?: observer.onBinningDataChanged(emptyList())
}
}
}
private fun readStepDailyTrend(dayStartTime: Long) {
val request = ReadRequest.Builder()
.setDataType(StepDailyTrend.HEALTH_DATA_TYPE)
.setProperties(arrayOf(StepDailyTrend.COUNT, StepDailyTrend.BINNING_DATA))
.setFilter(Filter.and(
Filter.eq(StepDailyTrend.DAY_TIME, dayStartTime),
Filter.eq(StepDailyTrend.SOURCE_TYPE, StepDailyTrend.SOURCE_TYPE_ALL)))
.build()
runCatching { healthDataResolver.read(request) }
.onFailure { Log.e(TAG, "Getting daily step trend fails.", it) }
.getOrNull()
?.setResultListener {
it.use {
it.firstOrNull().also {
observer.onChanged(it?.getInt(StepDailyTrend.COUNT) ?: 0)
observer.onBinningDataChanged(
it?.getBlob(StepDailyTrend.BINNING_DATA)?.let { getBinningData(it) } ?: emptyList())
}
}
}
}
private fun getBinningData(zip: ByteArray): List<StepBinningData> {
// decompress ZIP
val binningDataList = HealthDataUtil.getStructuredDataList(zip, StepBinningData::class.java)
return binningDataList.asSequence()
.withIndex()
.filter { it.value.count != 0 }
.onEach { it.value.time = String.format(Locale.US, "%02d:%02d", it.index / 6, it.index % 6 * 10) }
.map { it.value }
.toList()
}
private fun readStepCountBinning(startTime: Long, deviceUuid: String) {
// Get 10 minute binning data of a particular device
val request = AggregateRequest.Builder()
.setDataType(StepCount.HEALTH_DATA_TYPE)
.addFunction(AggregateFunction.SUM, StepCount.COUNT, ALIAS_TOTAL_COUNT)
.setTimeGroup(TimeGroupUnit.MINUTELY, 10, StepCount.START_TIME, StepCount.TIME_OFFSET, ALIAS_BINNING_TIME)
.setLocalTimeRange(StepCount.START_TIME, StepCount.TIME_OFFSET, startTime, startTime + TIME_INTERVAL)
.setFilter(Filter.eq(StepCount.DEVICE_UUID, deviceUuid))
.setSort(ALIAS_BINNING_TIME, SortOrder.ASC)
.build()
runCatching { healthDataResolver.aggregate(request) }
.onFailure { Log.e(TAG, "Getting step binning data fails.", it) }
.getOrNull()
?.setResultListener {
it.use {
it.asSequence()
.map { it.getString(ALIAS_BINNING_TIME) to it.getInt(ALIAS_TOTAL_COUNT) }
.filter { it.first != null }
.map { StepBinningData(it.first.split(" ")[1], it.second) }
.toList()
}
.also { observer.onBinningDataChanged(it) }
}
}
companion object {
val TODAY_START_UTC_TIME = todayStartUtcTime
val TIME_INTERVAL = TimeUnit.DAYS.toMillis(1)
private const val ALIAS_TOTAL_COUNT = "count"
private const val ALIAS_DEVICE_UUID = "deviceuuid"
private const val ALIAS_BINNING_TIME = "binning_time"
const val TAG = "SamsungHealth.StepCountReader"
private val todayStartUtcTime: Long
get() {
val today = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
today[Calendar.HOUR_OF_DAY] = 0
today[Calendar.MINUTE] = 0
today[Calendar.SECOND] = 0
today[Calendar.MILLISECOND] = 0
return today.timeInMillis
}
}
}

View File

@ -0,0 +1,16 @@
package com.dzeio.openhealth.core
import androidx.lifecycle.LiveData
import androidx.room.*
import com.dzeio.openhealth.db.entities.Weight
interface BaseDao<T> {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg obj: T)
@Update
fun update(vararg obj: T)
@Delete
fun delete(vararg obj: T)
}

View File

@ -0,0 +1,37 @@
package com.dzeio.openhealth.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.dzeio.openhealth.db.dao.WeightDao
import com.dzeio.openhealth.db.entities.Weight
@Database(entities = [Weight::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun weightDao() : WeightDao
companion object {
// For Singleton instantiation
@Volatile
private var INSTANCE: AppDatabase? = null
private const val DATABASE_NAME = "open_health"
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
DATABASE_NAME
)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
}
return instance
}
}
}
}

View File

@ -0,0 +1,24 @@
package com.dzeio.openhealth.db.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import androidx.room.OnConflictStrategy.REPLACE
import com.dzeio.openhealth.core.BaseDao
import com.dzeio.openhealth.db.entities.Weight
@Dao
interface WeightDao : BaseDao<Weight> {
@Query("SELECT * FROM Weight")
fun getAll(): List<Weight>
@Query("SELECT * FROM Weight where id = :weightId")
fun getOne(weightId: Long): Weight?
@Query("Select count(*) from Weight")
fun getCount(): Int
@Query("Select * FROM Weight WHERE id=(SELECT max(id) FROM Weight)")
fun last(): Weight?
}

View File

@ -0,0 +1,15 @@
package com.dzeio.openhealth.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.sql.Timestamp
@Entity()
data class Weight (
@PrimaryKey(autoGenerate = true) var id: Long = 0,
var weight: Float = 0f,
@ColumnInfo(index = true)
var timestamp: Long = System.currentTimeMillis(),
var source: String = ""
)

View File

@ -13,7 +13,9 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.dzeio.openhealth.connectors.ActionRequestCode import com.dzeio.openhealth.connectors.ActionRequestCode
import com.dzeio.openhealth.connectors.GoogleFit import com.dzeio.openhealth.connectors.GoogleFit
import com.dzeio.openhealth.connectors.samsunghealth.SamsungHealth
import com.dzeio.openhealth.databinding.FragmentHomeBinding import com.dzeio.openhealth.databinding.FragmentHomeBinding
import com.dzeio.openhealth.db.AppDatabase
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
@ -29,27 +31,48 @@ class HomeFragment : Fragment() {
private lateinit var fit: GoogleFit private lateinit var fit: GoogleFit
private lateinit var viewModel: HomeViewModel
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val homeViewModel = viewModel =
ViewModelProvider(this).get(HomeViewModel::class.java) ViewModelProvider(this).get(HomeViewModel::class.java)
_binding = FragmentHomeBinding.inflate(inflater, container, false) _binding = FragmentHomeBinding.inflate(inflater, container, false)
val root: View = binding.root val root: View = binding.root
binding.button.setOnClickListener { // binding.button.setOnClickListener {
fit = GoogleFit(requireActivity()) // fit = GoogleFit(requireActivity())
fit.import() // //fit.import()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fit.getHistory() // //fit.getHistory()
} // fit.importWeight()
// }
//
// //SamsungHealth(requireActivity()).importStepCount()
// }
viewModel.text.observe(viewLifecycleOwner) {
binding.textView2.text = it
} }
return root return root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val weight = AppDatabase.getInstance(requireContext()).weightDao().last()
if (weight == null) {
viewModel.text.postValue("No Weight Available")
} else {
viewModel.text.postValue("${weight.weight}kg\ndone at ${weight.timestamp}")
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null

View File

@ -2,12 +2,13 @@ package com.dzeio.openhealth.ui.home
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() { class HomeViewModel(
private val savedStateHandle: SavedStateHandle
private val _text = MutableLiveData<String>().apply { ) : ViewModel() {
val text = MutableLiveData<String>().apply {
value = "This is home Fragment" value = "This is home Fragment"
} }
val text: LiveData<String> = _text
} }

View File

@ -0,0 +1,74 @@
package com.dzeio.openhealth.ui.import
import android.app.ProgressDialog
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.dzeio.openhealth.connectors.GoogleFit
import com.dzeio.openhealth.databinding.FragmentImportBinding
class ImportFragment : Fragment() {
private var _binding: FragmentImportBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
private lateinit var viewModel: ImportViewModel
private lateinit var progressDialog: ProgressDialog
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
viewModel = ViewModelProvider(this).get(ImportViewModel::class.java)
_binding = FragmentImportBinding.inflate(inflater, container, false)
val root: View = binding.root
progressDialog = ProgressDialog(requireContext())
progressDialog.apply {
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
setCancelable(false)
}
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.importGoogleFit.setOnClickListener {
importFromGoogleFit()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
@RequiresApi(Build.VERSION_CODES.O)
fun importFromGoogleFit() {
viewModel.importProgressTotal.postValue(-1)
val google = GoogleFit(requireActivity())
progressDialog.show()
google.importWeight {
progressDialog.dismiss()
}
}
fun importFromSamsungHealth() {
}
}

View File

@ -0,0 +1,22 @@
package com.dzeio.openhealth.ui.import
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.dzeio.openhealth.connectors.GoogleFit
class ImportViewModel : ViewModel() {
val text = MutableLiveData<String>().apply {
value = "This is slideshow Fragment"
}
val importProgress = MutableLiveData<Int>().apply {
value = 0
}
// If -1 progress is undetermined
// If 0 no progress bar
// Else progress bar
val importProgressTotal = MutableLiveData<Int>().apply {
value = 0
}
}

View File

@ -1,42 +0,0 @@
package com.dzeio.openhealth.ui.slideshow
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.dzeio.openhealth.databinding.FragmentSlideshowBinding
class SlideshowFragment : Fragment() {
private var _binding: FragmentSlideshowBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val slideshowViewModel =
ViewModelProvider(this).get(SlideshowViewModel::class.java)
_binding = FragmentSlideshowBinding.inflate(inflater, container, false)
val root: View = binding.root
val textView: TextView = binding.textSlideshow
slideshowViewModel.text.observe(viewLifecycleOwner) {
textView.text = it
}
return root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -1,13 +0,0 @@
package com.dzeio.openhealth.ui.slideshow
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class SlideshowViewModel : ViewModel() {
private val _text = MutableLiveData<String>().apply {
value = "This is slideshow Fragment"
}
val text: LiveData<String> = _text
}

View File

@ -6,11 +6,12 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.home.HomeFragment"> tools:context=".ui.home.HomeFragment">
<Button <TextView
android:id="@+id/button" android:id="@+id/textView2"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Button" android:text="TextView"
tools:layout_editor_absoluteX="157dp" app:layout_constraintEnd_toEndOf="parent"
tools:layout_editor_absoluteY="341dp" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.import.ImportFragment">
<TextView
android:id="@+id/text_slideshow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:textAlignment="center"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.044" />
<Button
android:id="@+id/import_google_fit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Import From Google Fit"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_slideshow" />
<Button
android:id="@+id/import_samsung_health"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Import From Samsung Health"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.501"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/import_google_fit" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.slideshow.SlideshowFragment">
<TextView
android:id="@+id/text_slideshow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:textAlignment="center"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -13,8 +13,8 @@
android:icon="@drawable/ic_menu_gallery" android:icon="@drawable/ic_menu_gallery"
android:title="@string/menu_gallery" /> android:title="@string/menu_gallery" />
<item <item
android:id="@+id/nav_slideshow" android:id="@+id/nav_import"
android:icon="@drawable/ic_menu_slideshow" android:icon="@drawable/ic_menu_slideshow"
android:title="@string/menu_slideshow" /> android:title="@string/menu_import" />
</group> </group>
</menu> </menu>

View File

@ -18,8 +18,8 @@
tools:layout="@layout/fragment_gallery" /> tools:layout="@layout/fragment_gallery" />
<fragment <fragment
android:id="@+id/nav_slideshow" android:id="@+id/nav_import"
android:name="com.dzeio.openhealth.ui.slideshow.SlideshowFragment" android:name="com.dzeio.openhealth.ui.import.ImportFragment"
android:label="@string/menu_slideshow" android:label="@string/menu_import"
tools:layout="@layout/fragment_slideshow" /> tools:layout="@layout/fragment_import" />
</navigation> </navigation>

View File

@ -10,4 +10,5 @@
<string name="menu_home">Home</string> <string name="menu_home">Home</string>
<string name="menu_gallery">Gallery</string> <string name="menu_gallery">Gallery</string>
<string name="menu_slideshow">Slideshow</string> <string name="menu_slideshow">Slideshow</string>
<string name="menu_import">Import</string>
</resources> </resources>