1
0
mirror of https://github.com/dzeiocom/OpenHealth.git synced 2025-04-23 03:12:14 +00:00

feat: Add Steps counter to app

This commit is contained in:
Florian Bouillon 2022-07-18 18:26:50 +02:00
parent 82d6b04db2
commit c4c6e45687
33 changed files with 815 additions and 270 deletions

View File

@ -101,11 +101,14 @@ dependencies {
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'javax.inject:javax.inject:1'
implementation 'com.google.android.material:material:1.7.0-alpha02'
implementation 'com.google.android.material:material:1.7.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3"
// Settings
implementation "androidx.preference:preference-ktx:1.2.0"
@ -116,6 +119,11 @@ dependencies {
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.0'
// Paging
implementation "androidx.paging:paging-runtime:3.1.1"
implementation "androidx.paging:paging-runtime-ktx:3.1.1"
// Services
implementation 'androidx.work:work-runtime-ktx:2.7.1'
@ -128,8 +136,8 @@ dependencies {
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
// Hilt
implementation "com.google.dagger:hilt-android:2.40.5"
kapt "com.google.dagger:hilt-compiler:2.40.5"
implementation 'com.google.dagger:hilt-android:2.42'
kapt 'com.google.dagger:hilt-compiler:2.42'
// Google Fit
implementation "com.google.android.gms:play-services-fitness:21.1.0"
@ -137,11 +145,16 @@ dependencies {
// Samsung Health
implementation files('libs/samsung-health-data-1.5.0.aar')
implementation "com.google.code.gson:gson:2.8.9"
implementation 'com.google.code.gson:gson:2.9.0'
// ROOM
implementation "androidx.room:room-runtime:2.4.2"
kapt "androidx.room:room-compiler:2.4.2"
implementation "androidx.room:room-ktx:2.4.2"
testImplementation "androidx.room:room-testing:2.4.2"
// Futures
implementation 'com.google.guava:guava:31.1-jre'
implementation "androidx.concurrent:concurrent-futures:1.1.0"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.3'
}

View File

@ -34,7 +34,7 @@
android:value="@integer/google_play_services_version" />
<activity
android:name=".MainActivity"
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.OpenHealth.NoActionBar">
<intent-filter>
@ -43,6 +43,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".services.OpenHealthService"
android:permission="android.permission.ACTIVITY_RECOGNITION"
android:exported="false"/>
</application>
</manifest>

View File

@ -1,9 +1,7 @@
package com.dzeio.openhealth
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.HiltAndroidApp
@ -27,9 +25,15 @@ class Application : Application() {
val locale = Locale(lang)
Locale.setDefault(locale)
val overrideConfiguration = baseContext.resources.configuration
overrideConfiguration.locale = locale
val context: Context = createConfigurationContext(overrideConfiguration)
val resources: Resources = context.getResources()
// val overrideConfiguration = baseContext.resources.configuration
// overrideConfiguration.locale = locale
// val context: Context = createConfigurationContext(overrideConfiguration)
// val resources: Resources = context.resources
}
/**
* SharedPreferences Key
*/
object Settings {
}
}

View File

@ -0,0 +1,28 @@
package com.dzeio.openhealth.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import com.dzeio.openhealth.core.BaseAdapter
import com.dzeio.openhealth.core.BaseViewHolder
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.databinding.LayoutItemListBinding
class StepsAdapter() : BaseAdapter<Step, LayoutItemListBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding
get() = LayoutItemListBinding::inflate
var onItemClick: ((weight: Step) -> Unit)? = null
override fun onBindData(
holder: BaseViewHolder<LayoutItemListBinding>,
item: Step,
position: Int
) {
holder.binding.value.text = "${item.value}ml"
holder.binding.datetime.text = item.formatTimestamp()
holder.binding.edit.setOnClickListener {
onItemClick?.invoke(item)
}
}
}

View File

@ -32,7 +32,7 @@ abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewH
abstract fun onBindData(holder: BaseViewHolder<VB>, item: T, position: Int)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<VB> {
return BaseViewHolder<VB>(
return BaseViewHolder(
bindingInflater(LayoutInflater.from(parent.context), parent, false)
)
}

View File

@ -2,24 +2,20 @@ package com.dzeio.openhealth.core
import android.content.Context
import android.util.Log
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.Worker
import androidx.work.WorkerParameters
abstract class BaseService(context: Context, params: WorkerParameters) : Worker(context, params) {
companion object {
fun schedule(tag: String, request: WorkRequest, context: Context) {
WorkManager.getInstance(context)
.cancelAllWorkByTag(tag)
fun schedule(tag: String, request: PeriodicWorkRequest, context: Context) {
Log.d("OpenHealth/BaseService", "Scheduled Job $tag")
WorkManager.getInstance(context)
.enqueue(request)
.enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}

View File

@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepDao
import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.data.water.WaterDao
import com.dzeio.openhealth.data.weight.Weight
@ -12,7 +14,8 @@ import com.dzeio.openhealth.data.weight.WeightDao
@Database(
entities = [
Weight::class,
Water::class
Water::class,
Step::class
],
version = 1,
exportSchema = false
@ -23,6 +26,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun weightDao(): WeightDao
abstract fun waterDao(): WaterDao
abstract fun stepDao(): StepDao
companion object {
private const val DATABASE_NAME = "open_health"

View File

@ -0,0 +1,45 @@
package com.dzeio.openhealth.data.step
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.sql.Date
import java.text.DateFormat
import java.util.Calendar
import java.util.TimeZone
@Entity()
data class Step(
@PrimaryKey(autoGenerate = true) var id: Long = 0,
var value: Float = 0f,
@ColumnInfo(index = true)
/**
* Timestamp down to an hour
*
* ex: 2022-09-22 10:00:00
*/
var timestamp: Long = 0,
var source: String = "OpenHealth"
) {
init {
if (timestamp == 0L) {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
timestamp = cal.timeInMillis
}
}
fun formatTimestamp(): String = DateFormat.getDateInstance().format(Date(timestamp))
fun isToday(): Boolean {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
return timestamp == cal.timeInMillis
}
}

View File

@ -0,0 +1,35 @@
package com.dzeio.openhealth.data.step
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.dzeio.openhealth.core.BaseDao
import kotlinx.coroutines.flow.Flow
@Dao
interface StepDao : BaseDao<Step> {
@Query("SELECT * FROM Step ORDER BY timestamp DESC")
fun getAll(): Flow<List<Step>>
@Query("SELECT * FROM Step where id = :weightId")
fun getOne(weightId: Long): Flow<Step?>
@Query("Select count(*) from Step")
fun getCount(): Flow<Int>
@Query("Select * FROM Step ORDER BY timestamp DESC LIMIT 1")
fun last(): Flow<Step?>
@Query("DELETE FROM Step where source = :source")
suspend fun deleteFromSource(source: String)
@Update
fun updateNF(vararg obj: Step)
@Insert
fun insertNF(vararg obj: Step)
@Query("Select * FROM Step ORDER BY timestamp DESC LIMIT 1")
fun lastNF(): Step?
}

View File

@ -0,0 +1,26 @@
package com.dzeio.openhealth.data.step
import kotlinx.coroutines.flow.filter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StepRepository @Inject constructor(
private val stepDao: StepDao
) {
fun getSteps() = stepDao.getAll()
fun lastStep() = stepDao.last()
fun getStep(id: Long) = stepDao.getOne(id)
suspend fun addStep(value: Step) = stepDao.insert(value)
suspend fun updateStep(value: Step) = stepDao.update(value)
suspend fun deleteStep(value: Step) = stepDao.delete(value)
suspend fun deleteFromSource(value: String) = stepDao.deleteFromSource(value)
fun todayStep() = lastStep().filter {
return@filter it != null && it.isToday()
}
}

View File

@ -0,0 +1,90 @@
package com.dzeio.openhealth.data.step
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.SystemClock
import android.util.Log
import androidx.preference.PreferenceManager
import com.dzeio.openhealth.Application
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
class StepSource(
private val context: Context,
private val callback: ((Float) -> Unit)? = null
): SensorEventListener {
companion object {
const val TAG = "${Application.TAG}/StepSource"
}
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var timeSinceLastRecord: Long
get() {
return prefs.getLong("steps_time_since_last_record", Long.MAX_VALUE)
}
set(value) {
val editor = prefs.edit()
editor.putLong("steps_time_since_last_record", value)
editor.commit()
}
private var stepsAsOfLastRecord: Float
get() {
return prefs.getFloat("steps_as_of_last_record", 0f)
}
set(value) {
val editor = prefs.edit()
editor.putFloat("steps_as_of_last_record", value)
editor.commit()
}
init {
Log.d(TAG, "Setting up")
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val stepCountSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
stepCountSensor.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL, SensorManager.SENSOR_DELAY_NORMAL)
Log.d(TAG, "should be setup :D")
}
}
val events = Channel<Float>(10)
override fun onSensorChanged(ev: SensorEvent?) {
if (ev == null) {
return
}
// Log.d(TAG, "Sensor changed: $ev")
ev.values.firstOrNull()?.let {
val timeSinceLastBoot = System.currentTimeMillis() - SystemClock.elapsedRealtime()
val diff = it - stepsAsOfLastRecord
stepsAsOfLastRecord = it
// don't send changes since it wasn't made when the app was running
if (timeSinceLastBoot < timeSinceLastRecord) {
Log.d(TAG, "Skipping since we don't know when many steps are taken since last boot ($timeSinceLastRecord, $timeSinceLastBoot)")
timeSinceLastRecord = timeSinceLastBoot
return@let
}
timeSinceLastRecord = timeSinceLastBoot
runBlocking {
events.send(diff)
}
callback?.invoke(diff)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
Log.d(TAG, "[Accuracy changed]: Sensor: $sensor, Accuracy: $accuracy")
}
}

View File

@ -1,8 +1,8 @@
package com.dzeio.openhealth.data.weight
import androidx.room.*
import androidx.room.Dao
import androidx.room.Query
import com.dzeio.openhealth.core.BaseDao
import dagger.Provides
import kotlinx.coroutines.flow.Flow
@Dao
@ -11,10 +11,9 @@ interface WeightDao : BaseDao<Weight> {
@Query("SELECT * FROM Weight ORDER BY timestamp")
fun getAll(): Flow<List<Weight>>
@Query("SELECT * FROM Weight where id = :weightId")
@Query("SELECT * FROM Weight WHERE id = :weightId")
fun getOne(weightId: Long): Flow<Weight?>
@Query("Select count(*) from Weight")
fun getCount(): Flow<Int>

View File

@ -2,6 +2,7 @@ package com.dzeio.openhealth.di
import android.content.Context
import com.dzeio.openhealth.data.AppDatabase
import com.dzeio.openhealth.data.step.StepDao
import com.dzeio.openhealth.data.water.WaterDao
import com.dzeio.openhealth.data.weight.WeightDao
import dagger.Module
@ -30,4 +31,9 @@ class DatabaseModule {
fun provideWaterDao(appDatabase: AppDatabase): WaterDao {
return appDatabase.waterDao()
}
@Provides
fun provideStepsDao(appDatabase: AppDatabase): StepDao {
return appDatabase.stepDao()
}
}

View File

@ -9,9 +9,9 @@ import com.dzeio.openhealth.data.weight.Weight
/**
* Extension Schema
*
* Version: 1.0.0
* Version: 0.1.0
*/
abstract class Extension {
interface Extension {
data class ImportState<T>(
val state: States = States.WIP,
@ -33,6 +33,8 @@ abstract class Extension {
STEPS
/**
* Google Fit:
*
* STEP_COUNT_CUMULATIVE
* ACTIVITY_SEGMENT
* SLEEP_SEGMENT
@ -44,47 +46,52 @@ abstract class Extension {
*/
}
val permissions: Array<String>?
val permissionsText: String?
/**
* the Source ID
*
* DO NOT CHANGE IT AFTER THE EXTENSION IS IN PRODUCTION
*/
abstract val id: String
val id: String
/**
* The Extension Display Name
*/
abstract val name: String
val name: String
/**
* Initialize hte Extension
*
* It is run Before any functions is launched and after events handlers are set
*/
abstract fun init(activity: Activity): Array<Data>
fun init(activity: Activity): Array<Data>
/**
* A status shown on the extension list page
*/
open fun getStatus(): String {
fun getStatus(): String {
return "No Status set..."
}
/**
* Function that will check
*/
abstract fun isAvailable(): Boolean
fun isAvailable(): Boolean
/**
* idk
* return if the extension is already connected to remote of not
*/
abstract fun isConnected(): Boolean
fun isConnected(): Boolean
open fun connect(): LiveData<States> {
fun connect(): LiveData<States> {
return MutableLiveData(States.DONE)
}
open fun importWeight(): LiveData<ImportState<Weight>> {
fun importWeight(): LiveData<ImportState<Weight>> {
return MutableLiveData(ImportState(States.DONE))
}
@ -92,7 +99,7 @@ abstract class Extension {
* function run when outgoing sync is enabled and new value is added
* or manual export is launched
*/
open fun exportWeight(weight: Weight): LiveData<States> {
fun exportWeight(weight: Weight): LiveData<States> {
return MutableLiveData(States.DONE)
}
@ -100,20 +107,8 @@ abstract class Extension {
* Activity Events
*/
/**
* Same as Activity/Fragment onRequestPermissionResult
*
* But it will only be launched if grantResults[0] == PackageManager.PERMISSION_GRANTED
*/
open fun onRequestPermissionResult(
requestCode: Int,
permission: Array<String>,
grantResult: IntArray
) {
}
/**
* Same as Activity/Fragment onActivityResult
*/
open fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {}
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {}
}

View File

@ -3,10 +3,7 @@ package com.dzeio.openhealth.extensions
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.dzeio.openhealth.data.weight.Weight
@ -21,7 +18,7 @@ import java.util.Date
import java.util.TimeZone
import java.util.concurrent.TimeUnit
class GoogleFit() : Extension() {
class GoogleFit: Extension {
companion object {
const val TAG = "GoogleFitConnector"
}
@ -31,10 +28,16 @@ class GoogleFit() : Extension() {
override val id = "GoogleFit"
override val name = "Google Fit"
override fun init(activity: Activity): Array<Data> {
override val permissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
override val permissionsText: String = "Please"
override fun init(activity: Activity): Array<Extension.Data> {
this.activity = activity
return arrayOf(
Data.WEIGHT
Extension.Data.WEIGHT
)
}
@ -55,47 +58,14 @@ class GoogleFit() : Extension() {
// .addDataType(DataType.TYPE_CALORIES_EXPENDED)
.build()
// private fun checkPermissionsAndRun(data: Data) {
// if (permissionApproved()) {
// signIn(data)
// } else {
// Log.d(TAG, "Asking for permission")
// // Ask for permission
// ActivityCompat.requestPermissions(
// activity,
// arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
// data.ordinal
// )
// }
// }
private val connectLiveData: MutableLiveData<Extension.States> = MutableLiveData(Extension.States.WIP)
private fun permissionApproved(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
PackageManager.PERMISSION_GRANTED == ActivityCompat.checkSelfPermission(
activity,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
true
}
private val connectLiveData: MutableLiveData<States> = MutableLiveData(States.WIP)
override fun connect(): LiveData<States> {
if (!permissionApproved()) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
87531
)
return connectLiveData
}
override fun connect(): LiveData<Extension.States> {
if (isConnected()) {
connectLiveData.value = States.DONE
connectLiveData.value = Extension.States.DONE
} else {
Log.d("GoogleFitImporter", "Signing In")
Log.d(this.name, "Signing In")
GoogleSignIn.requestPermissions(
activity,
124887,
@ -105,19 +75,6 @@ class GoogleFit() : Extension() {
return connectLiveData
}
// private fun signIn(data: Data) {
// if (isConnected()) {
// startImport(data)
// } else {
// Log.d("GoogleFitImporter", "Signing In")
// GoogleSignIn.requestPermissions(
// activity,
// data.ordinal,
// getGoogleAccount(), fitnessOptions
// )
// }
// }
private fun getGoogleAccount() = GoogleSignIn.getAccountForExtension(activity, fitnessOptions)
private val timeRange by lazy {
@ -131,7 +88,7 @@ class GoogleFit() : Extension() {
return@lazy arrayOf(startTime, endTime)
}
private fun startImport(data: Data) {
private fun startImport(data: Extension.Data) {
Log.d("GoogleFitImporter", "Importing for ${data.name}")
val dateFormat = DateFormat.getDateInstance()
@ -142,7 +99,7 @@ class GoogleFit() : Extension() {
var timeUnit = TimeUnit.MILLISECONDS
when (data) {
Data.STEPS -> {
Extension.Data.STEPS -> {
type = DataType.TYPE_STEP_COUNT_CUMULATIVE
}
else -> {}
@ -157,7 +114,7 @@ class GoogleFit() : Extension() {
)
}
private fun runRequest(request: DataReadRequest, data: Data) {
private fun runRequest(request: DataReadRequest, data: Extension.Data) {
Fitness.getHistoryClient(
activity,
GoogleSignIn.getAccountForExtension(activity, fitnessOptions)
@ -191,7 +148,7 @@ class GoogleFit() : Extension() {
for (field in dp.dataType.fields) {
Log.i(TAG, "\tField: ${field.name} Value: ${dp.getValue(field)}")
when (data) {
Data.WEIGHT -> {
Extension.Data.WEIGHT -> {
val weight = Weight()
weight.timestamp = dp.getStartTime(TimeUnit.MILLISECONDS)
weight.weight = dp.getValue(field).asFloat()
@ -200,17 +157,17 @@ class GoogleFit() : Extension() {
list.add(weight)
weightLiveData.value =
ImportState(States.WIP, list)
Extension.ImportState(Extension.States.WIP, list)
}
else -> {}
}
}
}
when (data) {
Data.WEIGHT -> {
Extension.Data.WEIGHT -> {
weightLiveData.value =
ImportState(
States.DONE,
Extension.ImportState(
Extension.States.DONE,
weightLiveData.value?.list
?: ArrayList()
)
@ -224,39 +181,27 @@ class GoogleFit() : Extension() {
}
}
/**
* Currently not usable
*/
override fun onRequestPermissionResult(
requestCode: Int,
permission: Array<String>,
grantResult: IntArray
) {
connect()
// signIn(Data.values()[requestCode])
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
Log.d(this.name, "[$requestCode] -> [$resultCode]: $data")
if (requestCode == 0) {
return
}
connectLiveData.value = States.DONE
if (resultCode == Activity.RESULT_OK) connectLiveData.value = Extension.States.DONE
// signIn(Data.values()[requestCode])
}
private lateinit var weightLiveData: MutableLiveData<ImportState<Weight>>
private lateinit var weightLiveData: MutableLiveData<Extension.ImportState<Weight>>
override fun importWeight(): LiveData<ImportState<Weight>> {
override fun importWeight(): LiveData<Extension.ImportState<Weight>> {
weightLiveData = MutableLiveData(
ImportState(
States.WIP
Extension.ImportState(
Extension.States.WIP
)
)
startImport(Data.WEIGHT)
// checkPermissionsAndRun(Data.WEIGHT)
startImport(Extension.Data.WEIGHT)
return weightLiveData
}

View File

@ -1,10 +1,12 @@
package com.dzeio.openhealth.interfaces
import android.app.NotificationManager
enum class NotificationChannels(
val id: String,
val channelName: String,
val importance: Int
) {
// 3 is IMPORTANCE_DEFAULT
WATER("water", "Water Notifications", 3)
WATER("water", "Water Notifications", NotificationManager.IMPORTANCE_DEFAULT),
SERVICE("service", "Open Health Service", NotificationManager.IMPORTANCE_MIN)
}

View File

@ -1,5 +1,10 @@
package com.dzeio.openhealth.interfaces
enum class NotificationIds {
WaterIntake
WaterIntake,
/**
* Open Health Main Service Notification ID
*/
Service
}

View File

@ -0,0 +1,132 @@
package com.dzeio.openhealth.services
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.navigation.NavDeepLinkBuilder
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.R
import com.dzeio.openhealth.data.AppDatabase
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.data.step.StepRepository_Factory
import com.dzeio.openhealth.data.step.StepSource
import com.dzeio.openhealth.interfaces.NotificationChannels
import com.dzeio.openhealth.interfaces.NotificationIds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
class OpenHealthService : Service() {
companion object {
private const val TAG = "${Application.TAG}/OpenHealthService"
}
val stepRepository: StepRepository
get() = StepRepository_Factory.newInstance(AppDatabase.getInstance(applicationContext).stepDao())
private var mNM: NotificationManager? = null
// Unique Identification Number for the Notification.
// We use it on Notification start, and to cancel it.
private val NOTIFICATION: Int = 8942
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
override fun onCreate() {
mNM = getSystemService(NOTIFICATION_SERVICE) as NotificationManager?
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.i("LocalService", "Received start id $startId: $intent")
scope.launch {
val source = StepSource(this@OpenHealthService)
source.events.receiveAsFlow().collectLatest {
Log.d(TAG, "Received value: $it")
if (it <= 0f) {
Log.d(TAG, "No new steps registered ($it)")
return@collectLatest
}
Log.d(TAG, "New steps registered: $it")
val step = withTimeoutOrNull(100) {
return@withTimeoutOrNull stepRepository.todayStep().firstOrNull()
}
Log.d(TAG, "stepRepository: $step")
if (step != null) {
step.value += it
stepRepository.updateStep(step)
} else {
stepRepository.addStep(Step(value = it))
}
Log.d(TAG, "Added step!")
}
}
// Display a notification about us starting. We put an icon in the status bar.
startForeground(NotificationIds.Service.ordinal, showNotification())
return START_STICKY
}
override fun onDestroy() {
stopForeground(true)
// Tell the user we stopped.
Toast.makeText(this, "Service stopped", Toast.LENGTH_SHORT).show()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Show a notification while this service is running.
*/
private fun showNotification(): Notification {
// The PendingIntent to launch our activity if the user selects this notification
val flag =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0
val intent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.mobile_navigation)
.setDestination(R.id.nav_home)
// Will nav to water home when there will be a way to add it there
// .setDestination(R.id.nav_water_home)
.createTaskStackBuilder()
.getPendingIntent(0, flag)
// Set the info for the views that show in the notification panel.
val notification: Notification = NotificationCompat.Builder(this, NotificationChannels.SERVICE.id)
.setSmallIcon(R.drawable.ic_logo_small)
// .setTicker("Pouet") // the status text
.setWhen(System.currentTimeMillis()) // the time stamp
.setContentTitle("Open Health Service") // the label of the entry
.setContentText("Watching for your steps") // the contents of the entry
.setContentIntent(intent) // The intent to send when the entry is clicked
.build()
// Send the notification.
mNM!!.notify(NotificationIds.Service.ordinal, notification)
return notification
}
}

View File

@ -1,42 +0,0 @@
package com.dzeio.openhealth.services
import android.annotation.SuppressLint
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.util.Log
@SuppressLint("SpecifyJobSchedulerIdRange")
class StepCountService : JobService(), SensorEventListener {
override fun onStartJob(params: JobParameters?): Boolean {
Log.d("StepCountService", "Service Started")
val sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
val stepCountSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
stepCountSensor.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
}
return true
}
override fun onStopJob(params: JobParameters?): Boolean {
Log.d("StepCountService", "Service Stopped :(")
return true
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
Log.d("StepCountService", "Accuracy changed $sensor, $accuracy")
}
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
Log.d("StepCountService", "Event Triggered: $it")
it.values.firstOrNull()?.let { value ->
Log.d("StepCountService", "Step Count $value")
}
}
}
}

View File

@ -1,5 +1,6 @@
package com.dzeio.openhealth
package com.dzeio.openhealth.ui
import android.app.ActivityManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
@ -17,16 +18,22 @@ import androidx.navigation.ui.NavigationUI
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.work.WorkManager
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseActivity
import com.dzeio.openhealth.databinding.ActivityMainBinding
import com.dzeio.openhealth.interfaces.NotificationChannels
import com.dzeio.openhealth.services.WaterReminderService
import com.dzeio.openhealth.services.OpenHealthService
import com.dzeio.openhealth.workers.WaterReminderService
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>() {
companion object {
const val TAG = "${Application.TAG}/MainActivity"
}
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var navController: NavController
@ -57,21 +64,30 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
binding.bottomNav.setupWithNavController(navController)
// binding.bottomNav.setOnItemSelectedListener {
// val currentFragment = supportFragmentManager.fragments.last()
// // currentFragment.javaClass.canonicalName
// registerForActivityResult(ActivityResultContracts.RequestPermission()) {
//
// navController.
//
// false
// }
// .launch(Manifest.permission.ACTIVITY_RECOGNITION)
createNotificationChannel()
// Services
WorkManager.getInstance(this)
.cancelAllWork()
WaterReminderService.setup(this)
// StepCountService.setup(this)
this.betterStartService(OpenHealthService::class.java)
}
private fun <T> betterStartService(service: Class<T>) {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
for (runninService in activityManager.getRunningServices(Integer.MAX_VALUE)) {
if (service.name.equals(runninService.service.className)) {
Log.w(TAG, "Service already existing, not starting again")
return
}
}
Log.i(TAG, "Starting service ${service.name}")
Intent(this, service).also { intent -> startService(intent) }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -87,18 +103,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
NavigationUI.onNavDestinationSelected(item, navController) ||
super.onOptionsItemSelected(item)
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Log.d("MainActivity", "Result $requestCode")
for (fragment in supportFragmentManager.primaryNavigationFragment!!.childFragmentManager.fragments) {
fragment.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)

View File

@ -32,5 +32,9 @@ class BrowseFragment :
binding.waterIntake.setOnClickListener {
findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToNavWaterHome())
}
binding.steps.setOnClickListener {
findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToStepsHomeFragment())
}
}
}

View File

@ -1,22 +1,22 @@
package com.dzeio.openhealth.ui.extensions
import android.app.ProgressDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.dzeio.openhealth.R
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 com.dzeio.openhealth.extensions.GoogleFit
import com.dzeio.openhealth.utils.PermissionsManager
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@ -32,6 +32,16 @@ class ExtensionsFragment :
private lateinit var activeExtension: Extension
private val activityResult = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { map ->
if (map.containsValue(false)) {
// TODO: Show a popup with choice to change it
Toast.makeText(requireContext(), R.string.permission_declined, Toast.LENGTH_LONG).show()
return@registerForActivityResult
}
extensionIsConnected(activeExtension)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -43,20 +53,9 @@ class ExtensionsFragment :
val adapter = ExtensionAdapter()
adapter.onItemClick = {
activeExtension = it
Log.d(it.id, it.name)
if (it.isConnected()) {
Log.d(it.id, "Continue!")
findNavController().navigate(
ExtensionsFragmentDirections.actionNavExtensionsToNavExtension(
it.id
)
)
} else {
val ls = it.connect()
ls.observe(viewLifecycleOwner) { st ->
Log.d("States", st.name)
}
}
Log.d(TAG, "${it.id}: ${it.name}")
this.extensionPermissionsVerified(it)
}
recycler.adapter = adapter
@ -71,56 +70,43 @@ class ExtensionsFragment :
adapter.set(list)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
activeExtension.onActivityResult(requestCode, resultCode, data)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>,
grantResults: IntArray
) {
when {
grantResults.isEmpty() -> {
// If user interaction was interrupted, the permission request
// is cancelled and you receive empty arrays.
Log.i(TAG, "User interaction was cancelled.")
private fun extensionPermissionsVerified(it: Extension) {
// Check for the extension permissions
if (it.permissions != null && !PermissionsManager.hasPermission(requireContext(), it.permissions!!)) {
// TODO: show popup explaining the permissions requested
// show permissions
activityResult.launch(it.permissions)
return
}
extensionIsConnected(it)
}
grantResults[0] == PackageManager.PERMISSION_GRANTED -> {
Log.d(TAG, "Granted")
activeExtension.onRequestPermissionResult(requestCode, permissions, grantResults)
private fun extensionIsConnected(it: Extension) {
// check if it is connected
if (it.isConnected()) {
gotoExtension(it)
return
}
else -> {
// Permission denied.
// In this Activity we've chosen to notify the user that they
// have rejected a core permission for the app since it makes the Activity useless.
// We're communicating this message in a Snackbar since this is a sample app, but
// core permissions would typically be best requested during a welcome-screen flow.
// IDK: maybe give less liberty to the extension IDK
val ld = it.connect()
ld.observe(viewLifecycleOwner) { state ->
Log.d(TAG, state.toString())
if (state == Extension.States.DONE) {
gotoExtension(it)
}
}
}
// Additionally, it is important to remember that a permission might have been
// rejected without asking the user for permission (device policy or "Never ask
// again" prompts). Therefore, a user interface affordance is typically implemented
// when permissions are denied. Otherwise, your app could appear unresponsive to
// touches or interactions which have required permissions.
Log.e(TAG, "Error")
// Snackbar.make(
// findViewById(R.id.main_activity_view),
// R.string.permission_denied_explanation,
// Snackbar.LENGTH_INDEFINITE)
// .setAction(R.string.settings) {
// // Build intent that displays the App settings screen.
// val intent = Intent()
// intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
// val uri = Uri.fromParts("package",
// BuildConfig.APPLICATION_ID, null)
// intent.data = uri
// intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
// startActivity(intent)
// }
// .show()
}
}
private fun gotoExtension(it: Extension) {
findNavController().navigate(
ExtensionsFragmentDirections.actionNavExtensionsToNavExtension(it.id)
)
}
}

View File

@ -6,7 +6,6 @@ import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.RectF
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -24,9 +23,9 @@ import com.dzeio.openhealth.utils.DrawUtils
import com.dzeio.openhealth.utils.GraphUtils
import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.min
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.max
import kotlin.math.min
@AndroidEntryPoint
class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewModel::class.java) {
@ -204,7 +203,7 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
animator.addUpdateListener {
this.oldValue = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake.toFloat()
Log.d("Test2", "${this.oldValue}")
// Log.d("Test2", "${this.oldValue}")
DrawUtils.drawArc(
canvas,

View File

@ -0,0 +1,79 @@
package com.dzeio.openhealth.ui.steps
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.dzeio.openhealth.adapters.StepsAdapter
import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.databinding.FragmentStepsHomeBinding
import com.dzeio.openhealth.ui.water.StepsHomeViewModel
import com.dzeio.openhealth.utils.GraphUtils
import com.github.mikephil.charting.data.BarData
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.BarEntry
import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class StepsHomeFragment :
BaseFragment<StepsHomeViewModel, FragmentStepsHomeBinding>(StepsHomeViewModel::class.java) {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentStepsHomeBinding =
FragmentStepsHomeBinding::inflate
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.init()
val recycler = binding.list
val manager = LinearLayoutManager(requireContext())
recycler.layoutManager = manager
val adapter = StepsAdapter()
adapter.onItemClick = {
// findNavController().navigate(
// WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterEdit(
// it.id
// )
// )
}
recycler.adapter = adapter
val chart = binding.chart
GraphUtils.barChartSetup(
chart,
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorPrimary
),
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnBackground
)
)
chart.xAxis.valueFormatter = GraphUtils.DateValueFormatter(1000 * 60 * 60)
viewModel.items.observe(viewLifecycleOwner) { list ->
adapter.set(list)
val dataset = BarDataSet(
list.map {
return@map BarEntry(
(it.timestamp / 60 / 60 / 24).toFloat(),
it.value
)
},
""
)
chart.data = BarData(dataset)
chart.invalidate()
}
}
}

View File

@ -0,0 +1,26 @@
package com.dzeio.openhealth.ui.water
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class StepsHomeViewModel@Inject internal constructor(
private val stepRepository: StepRepository
) : BaseViewModel() {
val items: MutableLiveData<List<Step>> = MutableLiveData()
fun init() {
viewModelScope.launch {
stepRepository.getSteps().collectLatest {
items.postValue(it)
}
}
}
}

View File

@ -0,0 +1,20 @@
package com.dzeio.openhealth.utils
import android.content.Context
import android.content.pm.PackageManager
object PermissionsManager {
fun hasPermission(context: Context, permission: String): Boolean = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
fun hasPermission(context: Context, permissions: Array<String>): Boolean {
for (permission in permissions) {
val res = hasPermission(context, permission)
if (!res) {
return false
}
}
return true
}
}

View File

@ -0,0 +1,67 @@
package com.dzeio.openhealth.workers
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.PeriodicWorkRequest
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.core.BaseService
import com.dzeio.openhealth.data.AppDatabase
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.data.step.StepSource
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
@SuppressLint("SpecifyJobSchedulerIdRange")
class StepCountService(
private val context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
const val TAG = "${Application.TAG}/StepCountService"
fun setup(context: Context) {
BaseService.schedule(
TAG,
PeriodicWorkRequestBuilder<StepCountService>(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)
.addTag(TAG)
.build(),
context
)
}
}
override suspend fun doWork(): Result {
Log.d(TAG, "Service Started")
val appDatabase = AppDatabase.getInstance(this.context)
val repo = StepRepository(appDatabase.stepDao())
val source = StepSource(this.context)
val value = withTimeoutOrNull(10000) {
source.events.receive()
}
if (value == null || value == 0f) {
Log.d(TAG, "No new steps registered ($value)")
return Result.success()
}
Log.d(TAG, "New steps registered: $value")
coroutineContext
val step = repo.todayStep().first()
if (step != null) {
step.value += value
repo.updateStep(step)
} else {
repo.addStep(Step(value = value))
}
return Result.success()
}
}

View File

@ -1,9 +1,8 @@
package com.dzeio.openhealth.services
package com.dzeio.openhealth.workers
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
@ -12,12 +11,11 @@ import androidx.navigation.NavDeepLinkBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.MainActivity
import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseService
import com.dzeio.openhealth.interfaces.NotificationChannels
import com.dzeio.openhealth.interfaces.NotificationIds
import java.util.*
import java.util.Date
import java.util.concurrent.TimeUnit
class WaterReminderService(

View File

@ -14,7 +14,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:context=".MainActivity">
tools:context=".ui.MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
android:orientation="vertical"
tools:context=".ui.water.WaterHomeFragment">
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.github.mikephil.charting.charts.BarChart
android:id="@+id/chart"
android:layout_width="match_parent"
android:layout_height="200dp"
android:minHeight="200dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="16dp"
android:gravity="end"
android:orientation="horizontal"
android:weightSum="2">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_edit_default_intake"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
style="?attr/materialButtonOutlinedStyle"
android:text="@string/edit_daily_goal" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
tools:listitem="@layout/layout_item_list" />
</LinearLayout>

View File

@ -168,6 +168,9 @@
<action
android:id="@+id/action_nav_browse_to_nav_list_weight"
app:destination="@id/nav_list_weight" />
<action
android:id="@+id/action_nav_browse_to_stepsHomeFragment"
app:destination="@id/stepsHomeFragment" />
</fragment>
@ -176,4 +179,9 @@
android:name="com.dzeio.openhealth.ui.activity.ActivityFragment"
android:label="@string/menu_activity"
tools:layout="@layout/fragment_activity" />
<fragment
android:id="@+id/stepsHomeFragment"
android:name="com.dzeio.openhealth.ui.steps.StepsHomeFragment"
android:label="@string/menu_steps"
tools:layout="@layout/fragment_steps_home" />
</navigation>

View File

@ -37,5 +37,7 @@
<string name="menu_activity">Activity</string>
<string name="add_goal">Ajouter un objectif</string>
<string name="edit_goal">Modifier l\'objectif</string>
<string name="edit_daily_goal">Modifier le but journalier</string>
<string name="permission_declined">Vous avez décliné une permission, vous ne pouvez pas utiliser cette extension suaf si vous réactivez la permission manuellement</string>
</resources>

View File

@ -48,4 +48,7 @@
<string name="add_goal">Add Goal</string>
<string name="edit_goal">Modify Goal</string>
<string name="permission_declined">You declined a permission, you can\'t use this extension unless you enable it manually</string>
<string name="edit_daily_goal">Modifiy daily goal</string>
<string name="menu_steps">Steps</string>
</resources>