From c59481546f05b8ff3574918992472d6a8f0f115e Mon Sep 17 00:00:00 2001 From: Avior Date: Thu, 23 Feb 2023 17:35:45 +0100 Subject: [PATCH] feat: add back extensions --- .../dzeio/openhealth/extensions/Extension.kt | 143 ++++++++++++ .../openhealth/extensions/ExtensionFactory.kt | 37 +++ .../extensions/ExtensionsFragment.kt | 78 +++++++ .../extensions/ExtensionsViewModel.kt | 16 ++ .../extensions/FileSystemExtension.kt.old | 68 ++++++ .../openhealth/extensions/GoogleFit.kt.old | 210 ++++++++++++++++++ .../extensions/GoogleFitExtension.kt | 187 ++++++++++++++++ .../extensions/HealthConnectExtension.kt | 137 ++++++++++++ .../extensions/samsunghealth/SamsungHealth.kt | 112 ++++++++++ .../samsunghealth/StepCountReporter.kt | 88 ++++++++ .../ui/extension/ExtensionFragment.kt | 90 ++++++++ .../ui/extension/ExtensionViewModel.kt | 30 +++ .../ui/extensions/ExtensionsFragment.kt | 77 +++++++ .../ui/extensions/ExtensionsViewModel.kt | 16 ++ .../main/res/navigation/mobile_navigation.xml | 12 + 15 files changed, 1301 insertions(+) create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsFragment.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsViewModel.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt create mode 100644 app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt b/app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt new file mode 100644 index 0000000..fd45fec --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt @@ -0,0 +1,143 @@ +package com.dzeio.openhealth.extensions + +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContract +import androidx.fragment.app.Fragment +import com.dzeio.openhealth.data.weight.Weight +import kotlinx.coroutines.flow.Flow + +/** + * Extension Schema + * + * Version: 0.2.0 + */ +interface Extension : ActivityResultCallback { + + data class TaskProgress( + /** + * value indicating the current status of the task + */ + val state: TaskState = TaskState.INITIALIZATING, + + /** + * value between 0 and 100 indicating the progress for the task + */ + val progress: Float? = null, + + /** + * Additionnal message that will be displayed when the task has ended in a [TaskState.CANCELLED] or [TaskState.ERROR] state + */ + val statusMessage: String? = null, + + /** + * Additional information + */ + val additionalData: T? = null + ) + + enum class TaskState { + /** + * define the task as being preped + */ + INITIALIZATING, + + /** + * Define the task a bein worked on + */ + WORK_IN_PROGRESS, + + /** + * define the task as being done + */ + DONE, + + /** + * Define the task as being cancelled + */ + CANCELLED, + + /** + * define the task as being ended with an error + */ + ERROR + } + + enum class Data { + /** + * Special case to handle basic errors from other activities + */ + NOTHING, + WEIGHT, + STEPS + + /** + * Google Fit: + * + * STEP_COUNT_CUMULATIVE + * ACTIVITY_SEGMENT + * SLEEP_SEGMENT + * CALORIES_EXPENDED + * BASAL_METABOLIC_RATE + * POWER_SAMPLE + * HEART_RATE_BPM + * LOCATION_SAMPLE + */ + } + + /** + * the permissions necessary for the extension to works + */ + val permissions: Array + + /** + * the Source ID + * + * DO NOT CHANGE IT AFTER THE EXTENSION IS IN PRODUCTION + */ + val id: String + + /** + * The Extension Display Name + */ + val name: String + + /** + * the different types of data handled by the extension + */ + val data: Array + + /** + * Enable the extension, no code is gonna be run before + */ + fun enable(fragment: Fragment): Boolean + + /** + * return if the extension is already connected to remote of not + */ + suspend fun isConnected(): Boolean + + /** + * Return if the extension is runnable on the device + */ + fun isAvailable(): Boolean + + /** + * try to connect to remote + */ + suspend fun connect(): Boolean + + val contract: ActivityResultContract<*, *>? + val requestInput: Any? + suspend fun importWeight(): Flow>> + + /** + * function run when outgoing sync is enabled and new value is added + * or manual export is launched + */ + suspend fun exportWeights(weight: Array): Flow> + +// fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit + + + suspend fun permissionsGranted(): Boolean +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt new file mode 100644 index 0000000..df4b895 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt @@ -0,0 +1,37 @@ +package com.dzeio.openhealth.extensions + +import android.os.Build + +class ExtensionFactory { + companion object { + fun getExtension(extension: String): Extension? { + return when (extension) { + "GoogleFit" -> { + GoogleFitExtension() + } + "HealthConnect" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + HealthConnectExtension() + } else { + TODO("VERSION.SDK_INT < P") + } + } + else -> { + null + } + } + } + + fun getAll(): ArrayList { + val extensions: ArrayList = arrayListOf( + GoogleFitExtension() + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + extensions.add(HealthConnectExtension()) + } + + return extensions + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsFragment.kt b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsFragment.kt new file mode 100644 index 0000000..8186e99 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsFragment.kt @@ -0,0 +1,78 @@ +package com.dzeio.openhealth.ui.extensions + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.openhealth.adapters.ExtensionAdapter +import com.dzeio.openhealth.core.BaseFragment +import com.dzeio.openhealth.databinding.FragmentExtensionsBinding +import com.dzeio.openhealth.extensions.Extension +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class ExtensionsFragment : + BaseFragment(ExtensionsViewModel::class.java) { + + companion object { + const val TAG = "ExtensionsFragment" + } + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentExtensionsBinding = + FragmentExtensionsBinding::inflate + + private lateinit var activeExtension: Extension + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val recycler = binding.list + + val manager = LinearLayoutManager(requireContext()) + recycler.layoutManager = manager + + val adapter = ExtensionAdapter(viewModel.config) + adapter.onItemClick = { + activeExtension = it + activeExtension.enable(this) + Log.d(TAG, "${it.id}: ${it.name}") + + lifecycleScope.launch { + extensionIsConnected(it) + } + } + recycler.adapter = adapter + + val list = viewModel.extensions + list.forEach { + it.enable(this) + } + + adapter.set(list) + } + + private suspend fun extensionIsConnected(it: Extension) { + // check if it is connected + if (it.isConnected()) { + gotoExtension(it) + return + } + + val ld = it.connect() + if (ld) { + gotoExtension(it) + } + // handle if extension can't be connected + } + + private fun gotoExtension(it: Extension) { + findNavController().navigate( + ExtensionsFragmentDirections.actionNavExtensionsToNavExtension(it.id) + ) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsViewModel.kt new file mode 100644 index 0000000..646e69e --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionsViewModel.kt @@ -0,0 +1,16 @@ +package com.dzeio.openhealth.ui.extensions + +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.extensions.ExtensionFactory +import com.dzeio.openhealth.utils.Configuration +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ExtensionsViewModel @Inject internal constructor( + val config: Configuration +) : BaseViewModel() { + + val extensions = ExtensionFactory.getAll() + +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old b/app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old new file mode 100644 index 0000000..c5e6ec0 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old @@ -0,0 +1,68 @@ +package com.dzeio.openhealth.extensions + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.dzeio.openhealth.data.weight.Weight + +class FileSystemExtension : Extension { + companion object { + const val TAG = "FSExtension" + } + + private lateinit var activity: Activity + + override val id = "FileSystem" + override val name = "File System" + + override val permissions = arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + + override val permissionsText: String = "Please" + + override fun init(activity: Activity): Array { + this.activity = activity + return Extension.Data.values() + } + + override fun getStatus(): String { + return "" + } + + override fun isAvailable(): Boolean { + return true + } + + override fun isConnected(): Boolean = true + + private val connectLiveData: MutableLiveData = MutableLiveData(Extension.States.DONE) + + override fun connect(): LiveData = connectLiveData + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + Log.d(this.name, "[$requestCode] -> [$resultCode]: $data") + if (requestCode == 0) { + return + } + + if (resultCode == Activity.RESULT_OK) connectLiveData.value = Extension.States.DONE + // signIn(Data.values()[requestCode]) + } + + override fun importWeight(): LiveData> { + + weightLiveData = MutableLiveData( + Extension.ImportState( + Extension.States.WIP + ) + ) + + startImport(Extension.Data.WEIGHT) + + return weightLiveData + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old b/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old new file mode 100644 index 0000000..e5b5d40 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old @@ -0,0 +1,210 @@ +package com.dzeio.openhealth.extensions + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.dzeio.openhealth.data.weight.Weight +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.fitness.Fitness +import com.google.android.gms.fitness.FitnessOptions +import com.google.android.gms.fitness.data.DataType +import com.google.android.gms.fitness.request.DataReadRequest +import java.text.DateFormat +import java.util.Calendar +import java.util.Date +import java.util.TimeZone +import java.util.concurrent.TimeUnit + +class GoogleFit: Extension { + companion object { + const val TAG = "GoogleFitConnector" + } + + private lateinit var activity: Activity + + override val id = "GoogleFit" + override val name = "Google Fit" + + override val permissions = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + + override val permissionsText: String = "Please" + + override fun init(activity: Fragment): Array { + this.activity = activity.register + return arrayOf( + Extension.Data.WEIGHT + ) + } + + override fun getStatus(): String { + return if (isConnected()) "Connected" else "Not Connected" + } + + override fun isAvailable(): Boolean { + return true + } + + override fun isConnected(): Boolean = + GoogleSignIn.hasPermissions(getGoogleAccount(), fitnessOptions) + + private val fitnessOptions = FitnessOptions.builder() + .addDataType(DataType.TYPE_WEIGHT) +// .addDataType(DataType.TYPE_STEP_COUNT_CUMULATIVE) +// .addDataType(DataType.TYPE_CALORIES_EXPENDED) + .build() + + private val connectLiveData: MutableLiveData = MutableLiveData(Extension.States.WIP) + + override fun connect(): LiveData { + + if (isConnected()) { + connectLiveData.value = Extension.States.DONE + } else { + Log.d(this.name, "Signing In") + GoogleSignIn.requestPermissions( + activity, + 124887, + getGoogleAccount(), fitnessOptions + ) + } + return connectLiveData + } + + private fun getGoogleAccount() = GoogleSignIn.getAccountForExtension(activity, fitnessOptions) + + private val timeRange by lazy { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.time = Date() + val endTime = calendar.timeInMillis + + // Set year to 2013 to be sure to get data from when Google Fit Started to today + calendar.set(Calendar.YEAR, 2013) + val startTime = calendar.timeInMillis + return@lazy arrayOf(startTime, endTime) + } + + private fun startImport(data: Extension.Data) { + Log.d("GoogleFitImporter", "Importing for ${data.name}") + + val dateFormat = DateFormat.getDateInstance() + Log.i(TAG, "Range Start: ${dateFormat.format(timeRange[0])}") + Log.i(TAG, "Range End: ${dateFormat.format(timeRange[1])}") + + var type = DataType.TYPE_WEIGHT + var timeUnit = TimeUnit.MILLISECONDS + + when (data) { + Extension.Data.STEPS -> { + type = DataType.TYPE_STEP_COUNT_CUMULATIVE + } + else -> {} + } + + runRequest( + DataReadRequest.Builder() + .read(type) + .setTimeRange(timeRange[0], timeRange[1], timeUnit) + .build(), + data + ) + } + + private fun runRequest(request: DataReadRequest, data: Extension.Data) { + Fitness.getHistoryClient( + activity, + GoogleSignIn.getAccountForExtension(activity, fitnessOptions) + ) + .readData(request) + .addOnSuccessListener { response -> + Log.d( + TAG, + "Received response! ${response.dataSets.size} ${response.buckets.size} ${response.status}" + ) + for (dataSet in response.dataSets) { + Log.i( + TAG, + "Data returned for Data type: ${dataSet.dataType.name} ${dataSet.dataPoints.size} ${dataSet.dataSource}" + ) + dataSet.dataPoints.forEach { dp -> + + // Global + Log.i(TAG, "Importing Data point:") + Log.i(TAG, "\tType: ${dp.dataType.name}") + Log.i( + TAG, + "\tStart: ${Date(dp.getStartTime(TimeUnit.SECONDS) * 1000L).toLocaleString()}" + ) + Log.i( + TAG, + "\tEnd: ${Date(dp.getEndTime(TimeUnit.SECONDS) * 1000L).toLocaleString()}" + ) + + // Field Specifics + for (field in dp.dataType.fields) { + Log.i(TAG, "\tField: ${field.name} Value: ${dp.getValue(field)}") + when (data) { + Extension.Data.WEIGHT -> { + val weight = Weight() + weight.timestamp = dp.getStartTime(TimeUnit.MILLISECONDS) + weight.weight = dp.getValue(field).asFloat() + val list = weightLiveData.value?.list?.toMutableList() + ?: ArrayList() + list.add(weight) + weightLiveData.value = + + Extension.ImportState(Extension.States.WIP, list) + } + else -> {} + } + } + } + when (data) { + Extension.Data.WEIGHT -> { + weightLiveData.value = + Extension.ImportState( + Extension.States.DONE, + weightLiveData.value?.list + ?: ArrayList() + ) + } + else -> {} + } + } + } + .addOnFailureListener { e -> + Log.e(TAG, "There was an error reading data from Google Fit", e) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + Log.d(this.name, "[$requestCode] -> [$resultCode]: $data") + if (requestCode == 0) { + return + } + + if (resultCode == Activity.RESULT_OK) connectLiveData.value = Extension.States.DONE + // signIn(Data.values()[requestCode]) + } + + private lateinit var weightLiveData: MutableLiveData> + + override fun importWeight(): LiveData> { + + weightLiveData = MutableLiveData( + Extension.ImportState( + Extension.States.WIP + ) + ) + + startImport(Extension.Data.WEIGHT) + + return weightLiveData + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt b/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt new file mode 100644 index 0000000..a347645 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt @@ -0,0 +1,187 @@ +package com.dzeio.openhealth.extensions + +import android.Manifest +import android.util.Log +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import com.dzeio.openhealth.core.Observable +import com.dzeio.openhealth.data.weight.Weight +import com.dzeio.openhealth.utils.PermissionsManager +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.fitness.Fitness +import com.google.android.gms.fitness.FitnessOptions +import com.google.android.gms.fitness.data.DataType +import com.google.android.gms.fitness.request.DataReadRequest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.Calendar +import java.util.Date +import java.util.TimeZone +import java.util.concurrent.TimeUnit + +class GoogleFitExtension : Extension { + companion object { + const val TAG = "GoogleFitConnector" + } + + override val id = "GoogleFit" + override val name = "Google Fit" + + override val permissions = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + + override val data: Array = arrayOf( + Extension.Data.WEIGHT + ) + + override suspend fun isConnected(): Boolean = + GoogleSignIn.hasPermissions(getGoogleAccount(), fitnessOptions) + + + private val fitnessOptions = FitnessOptions.builder() + .addDataType(DataType.TYPE_WEIGHT) +// .addDataType(DataType.TYPE_STEP_COUNT_CUMULATIVE) +// .addDataType(DataType.TYPE_CALORIES_EXPENDED) + .build() + + private val connectionStatus = Observable(false) + + private lateinit var fragment: Fragment + + override fun isAvailable(): Boolean = true + + override fun enable(fragment: Fragment): Boolean { + this.fragment = fragment + return true + } + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun connect(): Boolean { + + if (isConnected()) { + return true + } + + return suspendCancellableCoroutine { cancellableContinuation -> + Log.d(this.name, "Signing In") + GoogleSignIn.requestPermissions( + fragment, + 124887, + getGoogleAccount(), fitnessOptions + ) + connectionStatus.addOneTimeObserver { it: Boolean -> + cancellableContinuation.resume(it) { + + } + } + } + } + + override val contract: ActivityResultContract<*, Map>? = ActivityResultContracts.RequestMultiplePermissions() + override val requestInput = permissions + + private fun getGoogleAccount() = GoogleSignIn.getAccountForExtension(fragment.requireContext(), fitnessOptions) + + private val timeRange by lazy { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.time = Date() + val endTime = calendar.timeInMillis + + // Set year to 2013 to be sure to get data from when Google Fit Started to today + calendar.set(Calendar.YEAR, 2013) + val startTime = calendar.timeInMillis + return@lazy arrayOf(startTime, endTime) + } + + override suspend fun importWeight(): Flow>> = + channelFlow { + send( + Extension.TaskProgress( + Extension.TaskState.INITIALIZATING + ) + ) + + val type = DataType.TYPE_WEIGHT + val timeUnit = TimeUnit.MILLISECONDS + + val request = DataReadRequest.Builder() + .read(type) + .setTimeRange(timeRange[0], timeRange[1], timeUnit) + .build() + + Fitness.getHistoryClient( + fragment.requireContext(), + GoogleSignIn.getAccountForExtension(fragment.requireContext(), fitnessOptions) + ) + .readData(request) + .addOnSuccessListener { response -> + val weights: ArrayList = ArrayList() + var index = 0 + var total = response.dataSets.size + for (dataset in response.dataSets) { + total += dataset.dataPoints.size - 1 + for (dataPoint in dataset.dataPoints) { + total += dataPoint.dataType.fields.size - 1 + for (field in dataPoint.dataType.fields) { + val weight = Weight().apply { + timestamp = dataPoint.getStartTime(TimeUnit.MILLISECONDS) + weight = dataPoint.getValue(field).asFloat() + source = this@GoogleFitExtension.id + } + weights.add(weight) + runBlocking { + send( + Extension.TaskProgress( + Extension.TaskState.WORK_IN_PROGRESS, + progress = index++ / total.toFloat() + ) + ) + } + } + } + } + runBlocking { + send( + Extension.TaskProgress( + Extension.TaskState.DONE, + additionalData = weights + ) + ) + } + } + .addOnFailureListener { + runBlocking { + send( + Extension.TaskProgress( + Extension.TaskState.ERROR, + statusMessage = it.localizedMessage ?: it.message ?: "Unknown error" + ) + ) + } + } + + + } + + override suspend fun exportWeights(weight: Array): Flow> { + TODO("Not yet implemented") + } + + override suspend fun permissionsGranted(): Boolean { + return PermissionsManager.hasPermission(this.fragment.requireContext(), permissions) + } + + override fun onActivityResult(result: Any) { + if ((result as Map<*, *>).containsValue(false)) { + return + } + + connectionStatus.value = true + } + +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt b/app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt new file mode 100644 index 0000000..808764e --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt @@ -0,0 +1,137 @@ +package com.dzeio.openhealth.extensions + +import android.Manifest +import android.os.Build +import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.RequiresApi +import androidx.fragment.app.Fragment +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import com.dzeio.openhealth.core.Observable +import com.dzeio.openhealth.data.weight.Weight +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.runBlocking +import java.time.Instant + +@RequiresApi(Build.VERSION_CODES.P) +class HealthConnectExtension : Extension { + companion object { + const val TAG = "HealthConnectExtension" + } + + // build a set of permissions for required data types + val PERMISSIONS = + setOf( + HealthPermission.createReadPermission(HeartRateRecord::class), + HealthPermission.createWritePermission(HeartRateRecord::class), + HealthPermission.createReadPermission(StepsRecord::class), + HealthPermission.createWritePermission(StepsRecord::class) + ) + + + override val id = "HealthConnect" + override val name = "Health Connect" + + override val permissions = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + + override val requestInput = PERMISSIONS + + override val data: Array = arrayOf( + Extension.Data.WEIGHT + ) + + override suspend fun isConnected(): Boolean = true + + + + private val connectionStatus = Observable(false) + + private lateinit var fragment: Fragment + private lateinit var client: HealthConnectClient + + override fun isAvailable(): Boolean { + return HealthConnectClient.isAvailable(fragment.requireContext()) + } + + override fun enable(fragment: Fragment): Boolean { + this.fragment = fragment + if (!isAvailable()) { + return false + } + + this.client = HealthConnectClient.getOrCreate(fragment.requireContext()) + + return true + } + + override suspend fun connect(): Boolean = true + + override suspend fun importWeight(): Flow>> = + channelFlow { + send( + Extension.TaskProgress( + Extension.TaskState.INITIALIZATING + ) + ) + + val response = client.readRecords( + ReadRecordsRequest( + WeightRecord::class, + timeRangeFilter = TimeRangeFilter.before(Instant.now()) + ) + ) + + val weights: ArrayList = ArrayList() + var index = 0 + for (record in response.records) { + val weight = Weight().apply { + timestamp = record.time.toEpochMilli() + weight = record.weight.inKilograms.toFloat() + source = this@HealthConnectExtension.id + } + weights.add(weight) + runBlocking { + send( + Extension.TaskProgress( + Extension.TaskState.WORK_IN_PROGRESS, + progress = index++ / response.records.size.toFloat() + ) + ) + } + } + runBlocking { + send( + Extension.TaskProgress( + Extension.TaskState.DONE, + additionalData = weights + ) + ) + } + + } + + override suspend fun exportWeights(weight: Array): Flow> { + TODO("Not yet implemented") + } + + override fun onActivityResult(result: Any) { + if ((result as Set<*>).containsAll(this.PERMISSIONS)) connectionStatus.value = true + // signIn(Data.values()[requestCode]) + } + + override val contract: ActivityResultContract, Set> + get() = PermissionController.createRequestPermissionResultContract() + + override suspend fun permissionsGranted(): Boolean { + return this.client.permissionController.getGrantedPermissions(this.PERMISSIONS).containsAll(this.PERMISSIONS) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt b/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt new file mode 100644 index 0000000..e301f7c --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt @@ -0,0 +1,112 @@ +package com.dzeio.openhealth.extensions.samsunghealth + +import android.app.Activity +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.dzeio.openhealth.data.weight.Weight +import com.samsung.android.sdk.healthdata.HealthConnectionErrorResult +import com.samsung.android.sdk.healthdata.HealthConstants.StepCount +import com.samsung.android.sdk.healthdata.HealthDataStore +import com.samsung.android.sdk.healthdata.HealthDataStore.ConnectionListener +import com.samsung.android.sdk.healthdata.HealthPermissionManager +import com.samsung.android.sdk.healthdata.HealthPermissionManager.* + + +/** + * Does not FUCKING work + */ +class SamsungHealth( + private val context: Activity +) { + + companion object { + const val TAG = "SamsungHealthConnector" + } + + private val listener = object : ConnectionListener { + override fun onConnected() { + Log.d(TAG, "Connected!") + if (isPermissionAcquired()) { + reporter.start() + } else { + requestPermission() + } + } + + override fun onConnectionFailed(p0: HealthConnectionErrorResult?) { + Log.d(TAG, "Health data service is not available.") + } + + override fun onDisconnected() { + Log.d(TAG, "Health data service is disconnected.") + } + + } + + private val store: HealthDataStore = HealthDataStore(context, listener) + + private fun isPermissionAcquired(): Boolean { + val permKey = PermissionKey(StepCount.HEALTH_DATA_TYPE, PermissionType.READ) + val pmsManager = HealthPermissionManager(store) + try { + // Check whether the permissions that this application needs are acquired + val resultMap = pmsManager.isPermissionAcquired(setOf(permKey)) + return !resultMap.containsValue(java.lang.Boolean.FALSE) + } catch (e: java.lang.Exception) { + Log.e(TAG, "Permission request fails.", e) + } + return false + } + + private fun requestPermission() { + val permKey = PermissionKey(StepCount.HEALTH_DATA_TYPE, PermissionType.READ) + val pmsManager = HealthPermissionManager(store) + try { + // Show user permission UI for allowing user to change options + pmsManager.requestPermissions(setOf(permKey), context) + .setResultListener { result: PermissionResult -> + Log.d(TAG, "Permission callback is received.") + val resultMap = + result.resultMap + if (resultMap.containsValue(java.lang.Boolean.FALSE)) { + Log.d(TAG, "No Data???") + } else { + // Get the current step count and display it + reporter.start() + } + } + } catch (e: Exception) { + Log.e(TAG, "Permission setting fails.", e) + } + } + + private val stepCountObserver = object : StepCountReporter.StepCountObserver { + override fun onChanged(count: Int) { + Log.d(TAG, "Step reported : $count") + } + } + + private val reporter = + StepCountReporter(store, stepCountObserver, Handler(Looper.getMainLooper())) + + /** + * Connector + */ + + val sourceID: String = "SamsungHealth" + + fun onRequestPermissionResult( + requestCode: Int, + permission: Array, + grantResult: IntArray + ) { + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {} + + fun importWeight(callback: (weight: Weight, end: Boolean) -> Unit) { + store.connectService() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt b/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt new file mode 100644 index 0000000..83a1731 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt @@ -0,0 +1,88 @@ +package com.dzeio.openhealth.extensions.samsunghealth + +import android.os.Handler +import android.util.Log +import com.samsung.android.sdk.healthdata.HealthConstants.StepCount +import com.samsung.android.sdk.healthdata.HealthData +import com.samsung.android.sdk.healthdata.HealthDataObserver +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.AggregateResult +import com.samsung.android.sdk.healthdata.HealthDataStore +import java.util.* +import java.util.concurrent.TimeUnit + +class StepCountReporter( + private val mStore: HealthDataStore, private val mStepCountObserver: StepCountObserver, + resultHandler: Handler? +) { + private val mHealthDataResolver: HealthDataResolver + private val mHealthDataObserver: HealthDataObserver + fun start() { + // Register an observer to listen changes of step count and get today step count + HealthDataObserver.addObserver(mStore, StepCount.HEALTH_DATA_TYPE, mHealthDataObserver) + readTodayStepCount() + } + + fun stop() { + HealthDataObserver.removeObserver(mStore, mHealthDataObserver) + } + + // Read the today's step count on demand + private fun readTodayStepCount() { + // Set time range from start time of today to the current time + val startTime = getUtcStartOfDay(System.currentTimeMillis(), TimeZone.getDefault()) + val endTime = startTime + TimeUnit.DAYS.toMillis(1) + val request = AggregateRequest.Builder() + .setDataType(StepCount.HEALTH_DATA_TYPE) + .addFunction(AggregateFunction.SUM, StepCount.COUNT, "total_step") + .setLocalTimeRange(StepCount.START_TIME, StepCount.TIME_OFFSET, startTime, endTime) + .build() + try { + mHealthDataResolver.aggregate(request) + .setResultListener { aggregateResult: AggregateResult -> + aggregateResult.use { result -> + val iterator: Iterator = result.iterator() + if (iterator.hasNext()) { + mStepCountObserver.onChanged(iterator.next().getInt("total_step")) + } + } + } + } catch (e: Exception) { + Log.e("APP_TAG", "Getting step count fails.", e) + } + } + + private fun getUtcStartOfDay(time: Long, tz: TimeZone): Long { + val cal = Calendar.getInstance(tz) + cal.timeInMillis = time + val year = cal[Calendar.YEAR] + val month = cal[Calendar.MONTH] + val date = cal[Calendar.DATE] + cal.timeZone = TimeZone.getTimeZone("UTC") + cal[Calendar.YEAR] = year + cal[Calendar.MONTH] = month + cal[Calendar.DATE] = date + cal[Calendar.HOUR_OF_DAY] = 0 + cal[Calendar.MINUTE] = 0 + cal[Calendar.SECOND] = 0 + cal[Calendar.MILLISECOND] = 0 + return cal.timeInMillis + } + + interface StepCountObserver { + fun onChanged(count: Int) + } + + init { + mHealthDataResolver = HealthDataResolver(mStore, resultHandler) + mHealthDataObserver = object : HealthDataObserver(resultHandler) { + // Update the step count when a change event is received + override fun onChange(dataTypeName: String) { + Log.d("APP_TAG", "Observer receives a data changed event") + readTodayStepCount() + } + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt new file mode 100644 index 0000000..c383d8d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt @@ -0,0 +1,90 @@ +package com.dzeio.openhealth.ui.extension + +import android.app.ProgressDialog +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs +import com.dzeio.openhealth.core.BaseFragment +import com.dzeio.openhealth.databinding.FragmentExtensionBinding +import com.dzeio.openhealth.extensions.Extension +import com.dzeio.openhealth.extensions.ExtensionFactory +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class ExtensionFragment : + BaseFragment(ExtensionViewModel::class.java) { + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentExtensionBinding = + FragmentExtensionBinding::inflate + + private val args: ExtensionFragmentArgs by navArgs() + + private val extension by lazy { + ExtensionFactory.getExtension(args.extension) + ?: throw Exception("No Extension found!") + } + + private var request: ActivityResultLauncher? = null + + override fun onAttach(context: Context) { + if (this.extension.contract != null) { + this.request = + registerForActivityResult(this.extension.contract!! as ActivityResultContract) { + this.extension.onActivityResult(it as Any) + } + } + super.onAttach(context) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val enabled = extension.enable(this) + + if (!enabled) { + throw Exception("Extension can't be enabled (${extension.id})") + } + + requireActivity().actionBar?.title = extension.name + +// extension.init(requireActivity()) + + binding.importButton.setOnClickListener { + val dialog = ProgressDialog(requireContext()) + dialog.setTitle("Importing...") + dialog.setMessage("Imported 0 values") + dialog.show() + lifecycleScope.launch { + extension.importWeight().collectLatest { state -> + Log.d("ExtensionFragment", state.state.name) + dialog.setMessage(state.statusMessage ?: "progress ${state.progress}%") + if (state.state == Extension.TaskState.DONE) { + dialog.setMessage("Finishing Import...") + lifecycleScope.launchWhenStarted { + state.additionalData!!.forEach { + it.source = extension.id + viewModel.importWeight(it) + } + dialog.dismiss() + } + } + } + } + } + + lifecycleScope.launch { + if (!extension.permissionsGranted() && request != null) { + request!!.launch(extension.requestInput) + } + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt new file mode 100644 index 0000000..74597fc --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt @@ -0,0 +1,30 @@ +package com.dzeio.openhealth.ui.extension + +import androidx.lifecycle.MutableLiveData +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.weight.Weight +import com.dzeio.openhealth.data.weight.WeightRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ExtensionViewModel @Inject internal constructor( + private val weightRepository: WeightRepository +) : BaseViewModel() { + + val text = MutableLiveData().apply { + value = "This is slideshow Fragment" + } + val importProgress = MutableLiveData().apply { + value = 0 + } + // If -1 progress is undetermined + // If 0 no progress bar + // Else progress bar + val importProgressTotal = MutableLiveData().apply { + value = 0 + } + + suspend fun importWeight(weight: Weight) = weightRepository.addWeight(weight) + suspend fun deleteFromSource(source: String) = weightRepository.deleteFromSource(source) +} \ No newline at end of file diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt new file mode 100644 index 0000000..2f2a661 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt @@ -0,0 +1,77 @@ +package com.dzeio.openhealth.ui.extensions + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.openhealth.core.BaseFragment +import com.dzeio.openhealth.databinding.FragmentExtensionsBinding +import com.dzeio.openhealth.extensions.Extension +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class ExtensionsFragment : + BaseFragment(ExtensionsViewModel::class.java) { + + companion object { + const val TAG = "ExtensionsFragment" + } + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentExtensionsBinding = + FragmentExtensionsBinding::inflate + + private lateinit var activeExtension: Extension + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val recycler = binding.list + + val manager = LinearLayoutManager(requireContext()) + recycler.layoutManager = manager + + val adapter = ExtensionAdapter(viewModel.config) + adapter.onItemClick = { + activeExtension = it + activeExtension.enable(this) + Log.d(TAG, "${it.id}: ${it.name}") + + lifecycleScope.launch { + extensionIsConnected(it) + } + } + recycler.adapter = adapter + + val list = viewModel.extensions + list.forEach { + it.enable(this) + } + + adapter.set(list) + } + + private suspend fun extensionIsConnected(it: Extension) { + // check if it is connected + if (it.isConnected()) { + gotoExtension(it) + return + } + + val ld = it.connect() + if (ld) { + gotoExtension(it) + } + // handle if extension can't be connected + } + + private fun gotoExtension(it: Extension) { + findNavController().navigate( + ExtensionsFragmentDirections.actionNavExtensionsToNavExtension(it.id) + ) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt new file mode 100644 index 0000000..646e69e --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt @@ -0,0 +1,16 @@ +package com.dzeio.openhealth.ui.extensions + +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.extensions.ExtensionFactory +import com.dzeio.openhealth.utils.Configuration +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ExtensionsViewModel @Inject internal constructor( + val config: Configuration +) : BaseViewModel() { + + val extensions = ExtensionFactory.getAll() + +} diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 5625f22..b23f0a5 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -212,4 +212,16 @@ tools:layout="@layout/dialog_search" android:name="com.dzeio.openhealth.ui.weight.ScanScalesDialog" android:label="ScanScalesDialog" /> + + + +