diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-12-01 04:31:36 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-12-01 04:31:36 +0000 |
commit | 1cefc66b91f828f77d70b6c29d8e5c5d577155f4 (patch) | |
tree | e304a2cca0dd19a5538a93d4223d6677633b8c20 | |
parent | 54eb260ab5ea2153110efb94cf73ad8b59e18eb3 (diff) | |
parent | 884b6debe1c18825a015de95ed8769c7d6f6aa92 (diff) | |
download | HealthFitness-android14-mainline-cellbroadcast-release.tar.gz |
Snap for 11164065 from 884b6debe1c18825a015de95ed8769c7d6f6aa92 to mainline-cellbroadcast-releaseaml_cbr_341410010android14-mainline-cellbroadcast-release
Change-Id: I77033e3224c769738434502131fc1fcc72b6f6bc
222 files changed, 8251 insertions, 2365 deletions
diff --git a/TEST_MAPPING b/TEST_MAPPING index 521bc661..2ed54e68 100644 --- a/TEST_MAPPING +++ b/TEST_MAPPING @@ -40,7 +40,12 @@ "name": "HealthFitnessIntegrationTests" }, { - "name": "HealthFitnessUnitTests" + "name": "HealthFitnessUnitTests", + "options": [ + { + "exclude-filter": "org.junit.Ignore" + } + ] }, { "name": "HealthConnectBackupRestoreUnitTests" diff --git a/apk/res/layout/migration_in_progress_screen.xml b/apk/res/layout/migration_in_progress_screen.xml index 4f52f065..23b8e2a9 100644 --- a/apk/res/layout/migration_in_progress_screen.xml +++ b/apk/res/layout/migration_in_progress_screen.xml @@ -55,13 +55,6 @@ android:indeterminate="true" /> - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/migration_in_progress_screen_integration_dont_close" - android:textAppearance="?attr/textAppearanceSubheader2" - /> - </LinearLayout> </ScrollView> diff --git a/apk/res/navigation/data_nav_graph.xml b/apk/res/navigation/data_nav_graph.xml index a12ddcf3..df80c113 100644 --- a/apk/res/navigation/data_nav_graph.xml +++ b/apk/res/navigation/data_nav_graph.xml @@ -71,6 +71,9 @@ <action android:id="@+id/action_healthPermissionTypes_to_unitsFragment" app:destination="@+id/unitFragment" /> + <action + android:id="@+id/action_healthPermissionTypes_to_dataSourcesAndPriority" + app:destination="@+id/dataSourcesFragment"/> </fragment> <fragment @@ -110,6 +113,26 @@ app:argType="string" /> </fragment> + <fragment + android:id="@+id/dataSourcesFragment" + android:name="com.android.healthconnect.controller.datasources.DataSourcesFragment" + android:label="@string/data_sources_and_priority_title"> + <action + android:id="@+id/action_dataSourcesFragment_to_addAnAppFragment" + app:destination="@id/addAnAppFragment"/> + </fragment> + + <fragment + android:id="@+id/addAnAppFragment" + android:name="com.android.healthconnect.controller.datasources.AddAnAppFragment" + android:label="@string/data_sources_add_app"> + <action + android:id="@+id/action_addAnAppFragment_to_dataSourcesFragment" + app:popUpTo="@id/dataSourcesFragment" + app:popUpToInclusive="true" + app:destination="@id/dataSourcesFragment"/> + </fragment> + <activity android:id="@+id/manageAppPermissions" app:action="android.health.connect.action.MANAGE_HEALTH_PERMISSIONS"> diff --git a/apk/res/values-af/strings.xml b/apk/res/values-af/strings.xml index fb602d2e..8bc248e3 100644 --- a/apk/res/values-af/strings.xml +++ b/apk/res/values-af/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Geen"</string> <string name="entry_details_title" msgid="590184849040247850">"Invoerbesonderhede"</string> <string name="backup_title" msgid="211503191266235085">"Rugsteun"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Databronne en -prioriteit"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Databronne en prioriteit"</string> <string name="set_units_title" msgid="2657822539603758029">"Stel eenhede"</string> <string name="recent_access_header" msgid="7623497371790225888">"Onlangse toegang"</string> <string name="no_recent_access" msgid="4724297929902441784">"Geen apps het onlangs toegang tot Health Connect gekry nie"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Hoe bronne en prioritisering werk"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Voeg ’n app by"</string> <string name="edit_data_sources" msgid="79641360876849547">"Wysig appbronne"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Toestelverstek"</string> <string name="app_data_title" msgid="6499967982291000837">"Appdata"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data vanaf apps met toegang tot Health Connect sal hier gewys word"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dag"</string> diff --git a/apk/res/values-am/strings.xml b/apk/res/values-am/strings.xml index f39dbd2a..87335ba3 100644 --- a/apk/res/values-am/strings.xml +++ b/apk/res/values-am/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ምንም"</string> <string name="entry_details_title" msgid="590184849040247850">"የግቤት ዝርዝሮች"</string> <string name="backup_title" msgid="211503191266235085">"ምትኬ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"የውሂብ ምንጮች እና ቅድሚያ"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"የውሂብ ምንጮች እና ቅድሚያ"</string> <string name="set_units_title" msgid="2657822539603758029">"ምደባዎችን አቀናብር"</string> <string name="recent_access_header" msgid="7623497371790225888">"የቅርብ ጊዜ መዳረሻ"</string> <string name="no_recent_access" msgid="4724297929902441784">"በቅርብ ጊዜ ምንም መተግበሪያዎች የጤና አገናኝን አልደረሱበትም"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ምንጮች እና ቅድሚያ አሰጣጥ እንዴት እንደሚሰሩ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"መተግበሪያ ያክሉ"</string> <string name="edit_data_sources" msgid="79641360876849547">"የመተግበሪያ ምንጮችን ያርትዑ"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"የመሣሪያ ነባሪ"</string> <string name="app_data_title" msgid="6499967982291000837">"የመተግበሪያ ውሂብ"</string> <string name="no_data_footer" msgid="4777297654713673100">"ወደ የጤና አገናኝ መዳረሻ ካላቸው መተግበሪያዎች ያለ ውሂብ እዚህ ይታያል"</string> <string name="date_picker_day" msgid="3076687507968958991">"ቀን"</string> diff --git a/apk/res/values-ar/strings.xml b/apk/res/values-ar/strings.xml index e544c1b9..5b27ae5a 100644 --- a/apk/res/values-ar/strings.xml +++ b/apk/res/values-ar/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ما مِن تطبيقات"</string> <string name="entry_details_title" msgid="590184849040247850">"تفاصيل الإدخال"</string> <string name="backup_title" msgid="211503191266235085">"الاحتفاظ بنسخة احتياطية"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"أولوية مصادر البيانات"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"مصادر البيانات وأولوية التطبيقات"</string> <string name="set_units_title" msgid="2657822539603758029">"ضبط الوحدات"</string> <string name="recent_access_header" msgid="7623497371790225888">"التطبيقات التي وصلت مؤخرًا إلى البيانات الصحية"</string> <string name="no_recent_access" msgid="4724297929902441784">"لم تستخدم أي تطبيقات Health Connect مؤخرًا."</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"آلية عمل أولوية المصادر"</string> <string name="data_sources_add_app" msgid="319926596123692514">"إضافة تطبيق"</string> <string name="edit_data_sources" msgid="79641360876849547">"تعديل مصادر التطبيقات"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"التطبيق التلقائي في الجهاز"</string> <string name="app_data_title" msgid="6499967982291000837">"بيانات التطبيق"</string> <string name="no_data_footer" msgid="4777297654713673100">"ستظهر هنا البيانات الواردة من التطبيقات التي يمكنها الوصول إلى Health Connect."</string> <string name="date_picker_day" msgid="3076687507968958991">"يوم"</string> diff --git a/apk/res/values-as/strings.xml b/apk/res/values-as/strings.xml index 2e816b5e..1ba2a040 100644 --- a/apk/res/values-as/strings.xml +++ b/apk/res/values-as/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"নাই"</string> <string name="entry_details_title" msgid="590184849040247850">"প্ৰৱিষ্টিৰ সবিশেষ"</string> <string name="backup_title" msgid="211503191266235085">"বেকআপ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ডেটাৰ উৎস আৰু অগ্ৰাধিকাৰ"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ডেটাৰ উৎস আৰু অগ্ৰাধিকাৰ"</string> <string name="set_units_title" msgid="2657822539603758029">"একক ছেট কৰক"</string> <string name="recent_access_header" msgid="7623497371790225888">"শেহতীয়া এক্সেছ"</string> <string name="no_recent_access" msgid="4724297929902441784">"শেহতীয়াকৈ কোনো এপে Health Connect এক্সেছ কৰা নাই"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ডেটাৰ উৎস আৰু অগ্ৰাধিকাৰে কেনেকৈ কাম কৰে"</string> <string name="data_sources_add_app" msgid="319926596123692514">"এটা এপ্ যোগ দিয়ক"</string> <string name="edit_data_sources" msgid="79641360876849547">"এপৰ উৎস সম্পাদনা কৰক"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ডিভাইচৰ ডিফ’ল্ট"</string> <string name="app_data_title" msgid="6499967982291000837">"এপৰ ডেটা"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connectৰ এক্সেছ থকা এপ্সমূহৰ ডেটা ইয়াত দেখুওৱা হ’ব"</string> <string name="date_picker_day" msgid="3076687507968958991">"দিন"</string> diff --git a/apk/res/values-az/strings.xml b/apk/res/values-az/strings.xml index 04be0b97..c486ccb3 100644 --- a/apk/res/values-az/strings.xml +++ b/apk/res/values-az/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Heç biri"</string> <string name="entry_details_title" msgid="590184849040247850">"Giriş detalları"</string> <string name="backup_title" msgid="211503191266235085">"Yedəkləyin"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Data mənbələri və prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Data mənbələri və prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Vahid təyini"</string> <string name="recent_access_header" msgid="7623497371790225888">"Son giriş"</string> <string name="no_recent_access" msgid="4724297929902441784">"Bu yaxınlarda heç bir tətbiq Health Connect-ə giriş etməyib"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Mənbə və prioritetləşdirmə haqqında"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Tətbiq əlavə edin"</string> <string name="edit_data_sources" msgid="79641360876849547">"Tətbiq mənbələrini redaktə edin"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Cihaz defoltu"</string> <string name="app_data_title" msgid="6499967982291000837">"Tətbiq datası"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect-ə girişi olan tətbiqlərin datası burada göstəriləcək"</string> <string name="date_picker_day" msgid="3076687507968958991">"Gün"</string> diff --git a/apk/res/values-b+sr+Latn/strings.xml b/apk/res/values-b+sr+Latn/strings.xml index 2a6878e6..af57f2ab 100644 --- a/apk/res/values-b+sr+Latn/strings.xml +++ b/apk/res/values-b+sr+Latn/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ništa"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalji unosa"</string> <string name="backup_title" msgid="211503191266235085">"Rezervna kopija"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Izvori podataka i prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Izvori podataka i prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Podesi jedinice"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nedavni pristup"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nijedna aplikacija nije nedavno pristupila Povezivanju zdravlja"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kako izvori i određivanje prioriteta rade"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Dodaj aplikaciju"</string> <string name="edit_data_sources" msgid="79641360876849547">"Izmeni izvore aplikacija"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Podrazumevano za uređaj"</string> <string name="app_data_title" msgid="6499967982291000837">"Podaci aplikacija"</string> <string name="no_data_footer" msgid="4777297654713673100">"Podaci iz aplikacija sa pristupom Povezivanju zdravlja prikazaće se ovde"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dan"</string> diff --git a/apk/res/values-be/strings.xml b/apk/res/values-be/strings.xml index 2dea3236..1d29a218 100644 --- a/apk/res/values-be/strings.xml +++ b/apk/res/values-be/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Няма"</string> <string name="entry_details_title" msgid="590184849040247850">"Звесткі пра ўвод"</string> <string name="backup_title" msgid="211503191266235085">"Рэзервовае капіраванне"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Крыніцы даных і прыярытэт"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Крыніцы даных і прыярытэт"</string> <string name="set_units_title" msgid="2657822539603758029">"Задаць адзінкі вымярэння"</string> <string name="recent_access_header" msgid="7623497371790225888">"Апошні доступ да даных геалакацыі"</string> <string name="no_recent_access" msgid="4724297929902441784">"У апошні час праграмы не атрымлівалі доступу да Здароўя і спорту"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Як працуюць крыніцы даных і прыярытэт"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Дадаць праграму"</string> <string name="edit_data_sources" msgid="79641360876849547">"Змяніць спіс праграм – крыніц даных"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Стандартная праграма прылады"</string> <string name="app_data_title" msgid="6499967982291000837">"Даныя праграмы"</string> <string name="no_data_footer" msgid="4777297654713673100">"Тут будуць паказвацца даныя з праграм, у якіх ёсць доступ да \"Здароўя і спорта\""</string> <string name="date_picker_day" msgid="3076687507968958991">"Дзень"</string> diff --git a/apk/res/values-bg/strings.xml b/apk/res/values-bg/strings.xml index 6b188984..37a8a9aa 100644 --- a/apk/res/values-bg/strings.xml +++ b/apk/res/values-bg/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Няма"</string> <string name="entry_details_title" msgid="590184849040247850">"Подробности за записа"</string> <string name="backup_title" msgid="211503191266235085">"Създаване на резервни копия"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Източници на данни и приоритет"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Източници на данни и приоритет"</string> <string name="set_units_title" msgid="2657822539603758029">"Задаване на мерни единици"</string> <string name="recent_access_header" msgid="7623497371790225888">"Скорошен достъп"</string> <string name="no_recent_access" msgid="4724297929902441784">"Няма приложения, наскоро осъществили достъп до Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Как работят източниците и задаването на приоритет"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Добавяне на приложение"</string> <string name="edit_data_sources" msgid="79641360876849547">"Редактиране на източниците на приложения"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Стандартно за устройството"</string> <string name="app_data_title" msgid="6499967982291000837">"Данни от приложението"</string> <string name="no_data_footer" msgid="4777297654713673100">"Тук ще се показват данните от приложенията, които имат достъп до Health Connect"</string> <string name="date_picker_day" msgid="3076687507968958991">"Ден"</string> diff --git a/apk/res/values-bn/strings.xml b/apk/res/values-bn/strings.xml index 001d1c35..fd5dd865 100644 --- a/apk/res/values-bn/strings.xml +++ b/apk/res/values-bn/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"কোনওটিই নয়"</string> <string name="entry_details_title" msgid="590184849040247850">"এন্ট্রি সংক্রান্ত বিবরণ"</string> <string name="backup_title" msgid="211503191266235085">"ব্যাক-আপ নিন"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ডেটা সোর্স এবং প্রায়োরিটি"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ডেটা সোর্স ও প্রায়োরিটি"</string> <string name="set_units_title" msgid="2657822539603758029">"ইউনিট সেট করুন"</string> <string name="recent_access_header" msgid="7623497371790225888">"সাম্প্রতিক অ্যাক্সেস"</string> <string name="no_recent_access" msgid="4724297929902441784">"সম্প্রতি কোনও অ্যাপ Health Connect অ্যাক্সেস করেনি"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"সোর্স এবং প্রায়োরিটি কীভাবে কাজ করে"</string> <string name="data_sources_add_app" msgid="319926596123692514">"অ্যাপ যোগ করুন"</string> <string name="edit_data_sources" msgid="79641360876849547">"অ্যাপের সোর্স এডিট করুন"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ডিভাইসের ডিফল্ট অ্যাপ"</string> <string name="app_data_title" msgid="6499967982291000837">"অ্যাপ ডেটা"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect-এর অ্যাক্সেস সহ অ্যাপ থেকে পাওয়া ডেটা এখানে দেখানো হবে"</string> <string name="date_picker_day" msgid="3076687507968958991">"দিনের"</string> diff --git a/apk/res/values-bs/strings.xml b/apk/res/values-bs/strings.xml index 523270a5..d0bffe4b 100644 --- a/apk/res/values-bs/strings.xml +++ b/apk/res/values-bs/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ništa"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalji unosa"</string> <string name="backup_title" msgid="211503191266235085">"Sigurnosna kopija"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Izvori podataka i prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Izvori podataka i prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Postavite jedinice"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nedavni pristup"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nijedna aplikacija nije nedavno pristupila Health Connectu"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kako funkcioniraju izvori i dodjeljivanje prioriteta"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Dodaj aplikaciju"</string> <string name="edit_data_sources" msgid="79641360876849547">"Uredi izvore aplikacija"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Zadana postavka uređaja"</string> <string name="app_data_title" msgid="6499967982291000837">"Podaci aplikacije"</string> <string name="no_data_footer" msgid="4777297654713673100">"Ovdje će se prikazivati podaci iz aplikacija s pristupom Health Connectu"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dan"</string> diff --git a/apk/res/values-ca/strings.xml b/apk/res/values-ca/strings.xml index b9d99b95..916b6cfe 100644 --- a/apk/res/values-ca/strings.xml +++ b/apk/res/values-ca/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Cap"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalls de l\'entrada"</string> <string name="backup_title" msgid="211503191266235085">"Còpia de seguretat"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Fonts de dades i prioritat"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Fonts de dades i prioritat"</string> <string name="set_units_title" msgid="2657822539603758029">"Defineix les unitats"</string> <string name="recent_access_header" msgid="7623497371790225888">"Accés recent"</string> <string name="no_recent_access" msgid="4724297929902441784">"Cap aplicació ha accedit a Salut connectada recentment"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Com funcionen les fonts i la priorització"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Afegeix una aplicació"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edita les fonts d\'aplicacions"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Opció predeterminada del dispositiu"</string> <string name="app_data_title" msgid="6499967982291000837">"Dades de l\'aplicació"</string> <string name="no_data_footer" msgid="4777297654713673100">"Les dades de les aplicacions amb accés a Salut connectada es mostraran aquí"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dia"</string> diff --git a/apk/res/values-cs/strings.xml b/apk/res/values-cs/strings.xml index bb6cf7f7..670001a2 100644 --- a/apk/res/values-cs/strings.xml +++ b/apk/res/values-cs/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Žádné"</string> <string name="entry_details_title" msgid="590184849040247850">"Podrobnosti o záznamu"</string> <string name="backup_title" msgid="211503191266235085">"Záloha"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Zdroje dat a priorita"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Zdroje dat a priorita"</string> <string name="set_units_title" msgid="2657822539603758029">"Nastavit jednotky"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nedávný přístup"</string> <string name="no_recent_access" msgid="4724297929902441784">"Žádné aplikace ke službě Health Connect v poslední době nepřistupovaly"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Jak fungují zdroje a určování priorit"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Přidat aplikaci"</string> <string name="edit_data_sources" msgid="79641360876849547">"Upravit zdroje aplikací"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Výchozí nastavení zařízení"</string> <string name="app_data_title" msgid="6499967982291000837">"Data aplikace"</string> <string name="no_data_footer" msgid="4777297654713673100">"Tady se budou zobrazovat data z aplikací s přístupem na platformu Health Connect"</string> <string name="date_picker_day" msgid="3076687507968958991">"Den"</string> diff --git a/apk/res/values-da/strings.xml b/apk/res/values-da/strings.xml index 98c58b41..8536a6bd 100644 --- a/apk/res/values-da/strings.xml +++ b/apk/res/values-da/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ingen"</string> <string name="entry_details_title" msgid="590184849040247850">"Oplysninger om dataposten"</string> <string name="backup_title" msgid="211503191266235085">"Sikkerhedskopiering"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datakilder og -prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datakilder og prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Angiv enheder"</string> <string name="recent_access_header" msgid="7623497371790225888">"Seneste adgang"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ingen apps har for nylig tilgået Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Sådan fungerer kilder og prioritering"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Tilføj en app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Rediger appkilder"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Enhedens standardapp"</string> <string name="app_data_title" msgid="6499967982291000837">"Appdata"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data fra apps med adgang til Health Connect vises her"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dag"</string> diff --git a/apk/res/values-de/strings.xml b/apk/res/values-de/strings.xml index 73c875db..5a89f789 100644 --- a/apk/res/values-de/strings.xml +++ b/apk/res/values-de/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Keine"</string> <string name="entry_details_title" msgid="590184849040247850">"Eintragsdetails"</string> <string name="backup_title" msgid="211503191266235085">"Back-up machen"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datenquellen und Priorität"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datenquellen und Priorität"</string> <string name="set_units_title" msgid="2657822539603758029">"Einheiten festlegen"</string> <string name="recent_access_header" msgid="7623497371790225888">"Letzte Zugriffe"</string> <string name="no_recent_access" msgid="4724297929902441784">"In letzter Zeit hat keine App auf Health Connect zugegriffen"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"So funktionieren Quellen und Priorisierung"</string> <string name="data_sources_add_app" msgid="319926596123692514">"App hinzufügen"</string> <string name="edit_data_sources" msgid="79641360876849547">"App-Quellen bearbeiten"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Standard-App bei diesem Gerät"</string> <string name="app_data_title" msgid="6499967982291000837">"App-Daten"</string> <string name="no_data_footer" msgid="4777297654713673100">"Daten aus Apps mit Zugriff auf Health Connect werden hier angezeigt"</string> <string name="date_picker_day" msgid="3076687507968958991">"Tag"</string> diff --git a/apk/res/values-el/strings.xml b/apk/res/values-el/strings.xml index ae441f5d..805697be 100644 --- a/apk/res/values-el/strings.xml +++ b/apk/res/values-el/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Καμία"</string> <string name="entry_details_title" msgid="590184849040247850">"Λεπτομέρειες καταχώρισης"</string> <string name="backup_title" msgid="211503191266235085">"Δημιουργία αντιγράφων ασφαλείας"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Πηγές δεδομένων και προτεραιότητα"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Πηγές δεδομένων και προτεραιότητα"</string> <string name="set_units_title" msgid="2657822539603758029">"Ορισμός μονάδων"</string> <string name="recent_access_header" msgid="7623497371790225888">"Πρόσφατη πρόσβαση"</string> <string name="no_recent_access" msgid="4724297929902441784">"Καμία εφαρμογή δεν απέκτησε πρόσφατα πρόσβαση στο Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Πώς λειτουργούν οι πηγές και η προτεραιότητα"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Προσθήκη εφαρμογής"</string> <string name="edit_data_sources" msgid="79641360876849547">"Επεξεργασία πηγών εφαρμογών"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Προεπιλογή συσκευής"</string> <string name="app_data_title" msgid="6499967982291000837">"Δεδομένα εφαρμογών"</string> <string name="no_data_footer" msgid="4777297654713673100">"Τα δεδομένα από εφαρμογές με πρόσβαση στο Health Connect θα εμφανίζονται εδώ"</string> <string name="date_picker_day" msgid="3076687507968958991">"Ημέρα"</string> diff --git a/apk/res/values-en-rAU/strings.xml b/apk/res/values-en-rAU/strings.xml index e835a41e..326bd150 100644 --- a/apk/res/values-en-rAU/strings.xml +++ b/apk/res/values-en-rAU/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"None"</string> <string name="entry_details_title" msgid="590184849040247850">"Entry details"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Data sources and priority"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Data sources and priority"</string> <string name="set_units_title" msgid="2657822539603758029">"Set units"</string> <string name="recent_access_header" msgid="7623497371790225888">"Recent access"</string> <string name="no_recent_access" msgid="4724297929902441784">"No apps recently accessed Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"How sources and prioritisation work"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Add an app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edit app sources"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Device default"</string> <string name="app_data_title" msgid="6499967982291000837">"App data"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data from apps with access to Health Connect will show here"</string> <string name="date_picker_day" msgid="3076687507968958991">"Day"</string> diff --git a/apk/res/values-en-rCA/strings.xml b/apk/res/values-en-rCA/strings.xml index c707d17e..7b20f194 100644 --- a/apk/res/values-en-rCA/strings.xml +++ b/apk/res/values-en-rCA/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"None"</string> <string name="entry_details_title" msgid="590184849040247850">"Entry details"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Data sources & priority"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Data sources and priority"</string> <string name="set_units_title" msgid="2657822539603758029">"Set units"</string> <string name="recent_access_header" msgid="7623497371790225888">"Recent access"</string> <string name="no_recent_access" msgid="4724297929902441784">"No apps recently accessed Health Connect"</string> diff --git a/apk/res/values-en-rGB/strings.xml b/apk/res/values-en-rGB/strings.xml index e835a41e..326bd150 100644 --- a/apk/res/values-en-rGB/strings.xml +++ b/apk/res/values-en-rGB/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"None"</string> <string name="entry_details_title" msgid="590184849040247850">"Entry details"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Data sources and priority"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Data sources and priority"</string> <string name="set_units_title" msgid="2657822539603758029">"Set units"</string> <string name="recent_access_header" msgid="7623497371790225888">"Recent access"</string> <string name="no_recent_access" msgid="4724297929902441784">"No apps recently accessed Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"How sources and prioritisation work"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Add an app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edit app sources"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Device default"</string> <string name="app_data_title" msgid="6499967982291000837">"App data"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data from apps with access to Health Connect will show here"</string> <string name="date_picker_day" msgid="3076687507968958991">"Day"</string> diff --git a/apk/res/values-en-rIN/strings.xml b/apk/res/values-en-rIN/strings.xml index e835a41e..326bd150 100644 --- a/apk/res/values-en-rIN/strings.xml +++ b/apk/res/values-en-rIN/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"None"</string> <string name="entry_details_title" msgid="590184849040247850">"Entry details"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Data sources and priority"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Data sources and priority"</string> <string name="set_units_title" msgid="2657822539603758029">"Set units"</string> <string name="recent_access_header" msgid="7623497371790225888">"Recent access"</string> <string name="no_recent_access" msgid="4724297929902441784">"No apps recently accessed Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"How sources and prioritisation work"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Add an app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edit app sources"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Device default"</string> <string name="app_data_title" msgid="6499967982291000837">"App data"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data from apps with access to Health Connect will show here"</string> <string name="date_picker_day" msgid="3076687507968958991">"Day"</string> diff --git a/apk/res/values-en-rXC/strings.xml b/apk/res/values-en-rXC/strings.xml index 1e2d87be..8dedcede 100644 --- a/apk/res/values-en-rXC/strings.xml +++ b/apk/res/values-en-rXC/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"None"</string> <string name="entry_details_title" msgid="590184849040247850">"Entry details"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Data sources & priority"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Data sources and priority"</string> <string name="set_units_title" msgid="2657822539603758029">"Set units"</string> <string name="recent_access_header" msgid="7623497371790225888">"Recent access"</string> <string name="no_recent_access" msgid="4724297929902441784">"No apps recently accessed Health Connect"</string> diff --git a/apk/res/values-es-rUS/strings.xml b/apk/res/values-es-rUS/strings.xml index 05eeff43..041c8f8e 100644 --- a/apk/res/values-es-rUS/strings.xml +++ b/apk/res/values-es-rUS/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ninguna"</string> <string name="entry_details_title" msgid="590184849040247850">"Información de la entrada"</string> <string name="backup_title" msgid="211503191266235085">"Copia de seguridad"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Prioridad y fuentes de datos"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Prioridad y fuentes de datos"</string> <string name="set_units_title" msgid="2657822539603758029">"Establecer unidades"</string> <string name="recent_access_header" msgid="7623497371790225888">"Acceso reciente"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ninguna app accedió recientemente a Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Cómo funcionan la priorización y las fuentes"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Agrega una app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editar fuentes de app"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Opción predeterminada del dispositivo"</string> <string name="app_data_title" msgid="6499967982291000837">"Datos de app"</string> <string name="no_data_footer" msgid="4777297654713673100">"Aquí se mostrará la información de apps con acceso a Health Connect"</string> <string name="date_picker_day" msgid="3076687507968958991">"Día"</string> diff --git a/apk/res/values-es/strings.xml b/apk/res/values-es/strings.xml index 84217b44..48fdd563 100644 --- a/apk/res/values-es/strings.xml +++ b/apk/res/values-es/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ninguna"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalles de la entrada"</string> <string name="backup_title" msgid="211503191266235085">"Copia de seguridad"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Fuentes de datos y prioridad"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Fuentes de datos y prioridad"</string> <string name="set_units_title" msgid="2657822539603758029">"Configurar unidades"</string> <string name="recent_access_header" msgid="7623497371790225888">"Acceso reciente"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ninguna aplicación ha accedido a Salud conectada recientemente"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Cómo funcionan las fuentes y la priorización"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Añadir una aplicación"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editar fuentes de la aplicación"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Predeterminado por el dispositivo"</string> <string name="app_data_title" msgid="6499967982291000837">"Datos de aplicaciones"</string> <string name="no_data_footer" msgid="4777297654713673100">"Los datos de las aplicaciones con acceso a Salud conectada se mostrarán aquí"</string> <string name="date_picker_day" msgid="3076687507968958991">"Día"</string> diff --git a/apk/res/values-et/strings.xml b/apk/res/values-et/strings.xml index 4854ec80..5f31814e 100644 --- a/apk/res/values-et/strings.xml +++ b/apk/res/values-et/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Pole"</string> <string name="entry_details_title" msgid="590184849040247850">"Kirje üksikasjad"</string> <string name="backup_title" msgid="211503191266235085">"Varundamine"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Andmeallikad ja prioriteetsus"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Andmeallikad ja prioriteet"</string> <string name="set_units_title" msgid="2657822539603758029">"Ühikute määramine"</string> <string name="recent_access_header" msgid="7623497371790225888">"Hiljutine juurdepääs"</string> <string name="no_recent_access" msgid="4724297929902441784">"Rakendusele Health Connect pole hiljuti ükski rakendus juurde pääsenud"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kuidas toimivad allikad ja prioriteetsus?"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Rakenduse lisamine"</string> <string name="edit_data_sources" msgid="79641360876849547">"Rakendusallikate muutmine"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Seadme vaikeseade"</string> <string name="app_data_title" msgid="6499967982291000837">"Rakenduse andmed"</string> <string name="no_data_footer" msgid="4777297654713673100">"Nende rakenduste andmed, millel on juurdepääs teenusele Health Connect, kuvatakse siin"</string> <string name="date_picker_day" msgid="3076687507968958991">"Päev"</string> diff --git a/apk/res/values-eu/strings.xml b/apk/res/values-eu/strings.xml index 51eea9bb..dc793eee 100644 --- a/apk/res/values-eu/strings.xml +++ b/apk/res/values-eu/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Batek ere ez"</string> <string name="entry_details_title" msgid="590184849040247850">"Sarreraren xehetasunak"</string> <string name="backup_title" msgid="211503191266235085">"Babeskopiak"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datu-iturburuak eta lehentasuna"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datu-iturburuak eta lehentasuna"</string> <string name="set_units_title" msgid="2657822539603758029">"Ezarritako unitateak"</string> <string name="recent_access_header" msgid="7623497371790225888">"Datuak atzitu dituzten azkenak"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ez dago azkenaldian Health Connect atzitu duen aplikaziorik"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Nola funtzionatzen dute iturburuek eta lehenespenak?"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Gehitu aplikazio bat"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editatu aplikazioen iturburuak"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Gailuko aplikazio lehenetsia"</string> <string name="app_data_title" msgid="6499967982291000837">"Aplikazioko datuak"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect erabil dezaketen aplikazioetako datuak agertuko dira hemen"</string> <string name="date_picker_day" msgid="3076687507968958991">"Egunekoak"</string> diff --git a/apk/res/values-fa/strings.xml b/apk/res/values-fa/strings.xml index 4306fc5e..df57cadc 100644 --- a/apk/res/values-fa/strings.xml +++ b/apk/res/values-fa/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"هیچکدام"</string> <string name="entry_details_title" msgid="590184849040247850">"جزئیات ورود"</string> <string name="backup_title" msgid="211503191266235085">"پشتیبانگیری"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"منابع داده و اولویت"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"منابع داده و اولویت"</string> <string name="set_units_title" msgid="2657822539603758029">"تنظیم واحدها"</string> <string name="recent_access_header" msgid="7623497371790225888">"دسترسی اخیر"</string> <string name="no_recent_access" msgid="4724297929902441784">"هیچ برنامهای اخیراً به Health Connect دسترسی نداشته است"</string> @@ -99,8 +99,8 @@ <string name="distance_lowercase_label" msgid="2287154001209381379">"مسافت"</string> <string name="distance_read_content_description" msgid="8787235642020285789">"خواندن داده مسافت"</string> <string name="distance_write_content_description" msgid="494549494589487562">"نوشتن داده مسافت"</string> - <string name="elevation_gained_uppercase_label" msgid="7708101940695442377">"ارتفاع صعودکرده"</string> - <string name="elevation_gained_lowercase_label" msgid="7532517182346738562">"ارتفاع صعودکرده"</string> + <string name="elevation_gained_uppercase_label" msgid="7708101940695442377">"ارتفاع صعودشده"</string> + <string name="elevation_gained_lowercase_label" msgid="7532517182346738562">"ارتفاع صعودشده"</string> <string name="elevation_gained_read_content_description" msgid="6018756385903843355">"خواندن داده ارتفاع صعودشده"</string> <string name="elevation_gained_write_content_description" msgid="6790199544670231367">"نوشتن داده ارتفاع صعودشده"</string> <string name="floors_climbed_uppercase_label" msgid="3754372357767832441">"طبقات پیمودهشده"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"نحوه عملکرد منابع و اولویتبندی"</string> <string name="data_sources_add_app" msgid="319926596123692514">"افزودن برنامه"</string> <string name="edit_data_sources" msgid="79641360876849547">"ویرایش منابع برنامه"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"پیشفرض دستگاه"</string> <string name="app_data_title" msgid="6499967982291000837">"دادههای برنامه"</string> <string name="no_data_footer" msgid="4777297654713673100">"دادهها از برنامههایی که به Health Connect دسترسی دارند اینجا نشان داده خواهد شد"</string> <string name="date_picker_day" msgid="3076687507968958991">"روز"</string> diff --git a/apk/res/values-fi/strings.xml b/apk/res/values-fi/strings.xml index c70ce178..db4d336e 100644 --- a/apk/res/values-fi/strings.xml +++ b/apk/res/values-fi/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"–"</string> <string name="entry_details_title" msgid="590184849040247850">"Tiedot"</string> <string name="backup_title" msgid="211503191266235085">"Varmuuskopiointi"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datalähteet ja prioriteetti"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datalähteet ja prioriteetit"</string> <string name="set_units_title" msgid="2657822539603758029">"Aseta yksiköt"</string> <string name="recent_access_header" msgid="7623497371790225888">"Viimeaikainen käyttö"</string> <string name="no_recent_access" msgid="4724297929902441784">"Sovellukset eivät ole käyttäneet Health Connectia äskettäin"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Miten lähteet ja priorisointi toimivat"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Sovelluksen lisääminen"</string> <string name="edit_data_sources" msgid="79641360876849547">"Muokkaa sovelluslähteitä"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Laitteen oletusasetus"</string> <string name="app_data_title" msgid="6499967982291000837">"Sovellusdata"</string> <string name="no_data_footer" msgid="4777297654713673100">"Täällä näkyy data sovelluksilta, joilla on pääsy Health Connectiin"</string> <string name="date_picker_day" msgid="3076687507968958991">"Päivä"</string> diff --git a/apk/res/values-fr-rCA/strings.xml b/apk/res/values-fr-rCA/strings.xml index 5d49d2d3..3b694e0f 100644 --- a/apk/res/values-fr-rCA/strings.xml +++ b/apk/res/values-fr-rCA/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Aucune"</string> <string name="entry_details_title" msgid="590184849040247850">"Détails de l\'entrée"</string> <string name="backup_title" msgid="211503191266235085">"Sauvegarde"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Sources de données et priorité"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Source de données et priorité"</string> <string name="set_units_title" msgid="2657822539603758029">"Configurer les unités"</string> <string name="recent_access_header" msgid="7623497371790225888">"Accès récents"</string> <string name="no_recent_access" msgid="4724297929902441784">"Aucune application n\'a accédé à Connexion santé dernièrement"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Fonctionnement des sources et de la hiérarchisation"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Ajouter une application"</string> <string name="edit_data_sources" msgid="79641360876849547">"Modifier les sources d\'applications"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Valeur par défaut de l\'appareil"</string> <string name="app_data_title" msgid="6499967982291000837">"Données de l\'application"</string> <string name="no_data_footer" msgid="4777297654713673100">"Les données des applications qui ont accès à Connexion santé s\'afficheront ici"</string> <string name="date_picker_day" msgid="3076687507968958991">"Jour"</string> diff --git a/apk/res/values-fr/strings.xml b/apk/res/values-fr/strings.xml index e713f984..992ce9e9 100644 --- a/apk/res/values-fr/strings.xml +++ b/apk/res/values-fr/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Aucune"</string> <string name="entry_details_title" msgid="590184849040247850">"Détails de l\'entrée"</string> <string name="backup_title" msgid="211503191266235085">"Sauvegarde"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Priorité des sources de données"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Sources de données et priorité"</string> <string name="set_units_title" msgid="2657822539603758029">"Définir des unités"</string> <string name="recent_access_header" msgid="7623497371790225888">"Accès récent"</string> <string name="no_recent_access" msgid="4724297929902441784">"Aucune appli n\'a accédé à Santé Connect récemment"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Fonctionnement de la priorisation des sources"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Ajouter une application"</string> <string name="edit_data_sources" msgid="79641360876849547">"Modifier les sources d\'application"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Paramètre par défaut"</string> <string name="app_data_title" msgid="6499967982291000837">"Données d\'application"</string> <string name="no_data_footer" msgid="4777297654713673100">"Les données issues des applications ayant accès à Santé Connect s\'afficheront ici."</string> <string name="date_picker_day" msgid="3076687507968958991">"Jour"</string> diff --git a/apk/res/values-gl/strings.xml b/apk/res/values-gl/strings.xml index 61196add..6f59eae8 100644 --- a/apk/res/values-gl/strings.xml +++ b/apk/res/values-gl/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ningunha"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalles da entrada"</string> <string name="backup_title" msgid="211503191266235085">"Copia de seguranza"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Fontes de datos e prioridade"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Prioridade e fontes de datos"</string> <string name="set_units_title" msgid="2657822539603758029">"Configurar unidades"</string> <string name="recent_access_header" msgid="7623497371790225888">"Acceso recente"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ningunha aplicación accedeu a Saúde conectada recentemente"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Funcionamento das fontes e da priorización"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Engadir unha aplicación"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editar fontes de aplicacións"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Predeterminada do dispositivo"</string> <string name="app_data_title" msgid="6499967982291000837">"Datos da aplicación"</string> <string name="no_data_footer" msgid="4777297654713673100">"Mostraranse aquí os datos das aplicacións con acceso a Saúde conectada"</string> <string name="date_picker_day" msgid="3076687507968958991">"Día"</string> diff --git a/apk/res/values-gu/strings.xml b/apk/res/values-gu/strings.xml index e4de9dfc..62501752 100644 --- a/apk/res/values-gu/strings.xml +++ b/apk/res/values-gu/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"એકપણ નહીં"</string> <string name="entry_details_title" msgid="590184849040247850">"એન્ટ્રીની વિગતો"</string> <string name="backup_title" msgid="211503191266235085">"બૅકઅપ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ડેટા સૉર્સ & પ્રાધાન્યતા"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ડેટા સૉર્સ અને પ્રાધાન્યતા"</string> <string name="set_units_title" msgid="2657822539603758029">"એકમો સેટ કરો"</string> <string name="recent_access_header" msgid="7623497371790225888">"તાજેતરનો ઍક્સેસ"</string> <string name="no_recent_access" msgid="4724297929902441784">"કોઈ ઍપ દ્વારા તાજેતરમાં Health Connectનો ઍક્સેસ કરવામાં આવ્યો નથી"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"સૉર્સના & પ્રાધાન્યતા આપવાના સેટિંગની કાર્ય કરવાની રીત"</string> <string name="data_sources_add_app" msgid="319926596123692514">"કોઈ ઍપ ઉમેરો"</string> <string name="edit_data_sources" msgid="79641360876849547">"ઍપના સૉર્સમાં ફેરફાર કરો"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ડિવાઇસ પર ડિફૉલ્ટ"</string> <string name="app_data_title" msgid="6499967982291000837">"ઍપનો ડેટા"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connectનો ઍક્સેસ ધરાવતી ઍપનો ડેટા અહીં દેખાશે"</string> <string name="date_picker_day" msgid="3076687507968958991">"દિવસ"</string> diff --git a/apk/res/values-hi/strings.xml b/apk/res/values-hi/strings.xml index 2e3df20b..4bbbf134 100644 --- a/apk/res/values-hi/strings.xml +++ b/apk/res/values-hi/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"कोई नहीं"</string> <string name="entry_details_title" msgid="590184849040247850">"एंट्री के बारे में जानकारी"</string> <string name="backup_title" msgid="211503191266235085">"बैकअप लें"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"डेटा सोर्स और प्राथमिकता की सेटिंग"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"डेटा सोर्स और प्राथमिकता"</string> <string name="set_units_title" msgid="2657822539603758029">"इकाइयां सेट करें"</string> <string name="recent_access_header" msgid="7623497371790225888">"हाल ही में, डेटा ऐक्सेस करने वाले ऐप्लिकेशन"</string> <string name="no_recent_access" msgid="4724297929902441784">"हाल ही में, किसी भी ऐप्लिकेशन ने Health Connect के डेटा का इस्तेमाल नहीं किया है"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"सोर्स और प्राथमिकता की सेटिंग कैसे काम करती है"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ऐप्लिकेशन जोड़ें"</string> <string name="edit_data_sources" msgid="79641360876849547">"ऐप्लिकेशन के सोर्स बदलें"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"डिवाइस की डिफ़ॉल्ट सेटिंग"</string> <string name="app_data_title" msgid="6499967982291000837">"ऐप्लिकेशन का डेटा"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect का ऐक्सेस रखने वाले ऐप्लिकेशन का डेटा यहां दिखेगा"</string> <string name="date_picker_day" msgid="3076687507968958991">"दिन"</string> diff --git a/apk/res/values-hr/strings.xml b/apk/res/values-hr/strings.xml index 9743acfb..06263bbb 100644 --- a/apk/res/values-hr/strings.xml +++ b/apk/res/values-hr/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ništa"</string> <string name="entry_details_title" msgid="590184849040247850">"Pojedinosti o unosu"</string> <string name="backup_title" msgid="211503191266235085">"Sigurnosna kopija"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Izvori podataka i prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Izvori podataka i prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Postavi jedinice"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nedavni pristup"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nijedna aplikacija nije pristupila Health Connectu u posljednje vrijeme"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kako funkcioniraju izvori i prioritizacija"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Dodaj aplikaciju"</string> <string name="edit_data_sources" msgid="79641360876849547">"Uređivanje izvora aplikacija"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Zadana postavka uređaja"</string> <string name="app_data_title" msgid="6499967982291000837">"Podaci aplikacije"</string> <string name="no_data_footer" msgid="4777297654713673100">"Ovdje će se prikazati podaci iz aplikacija s pristupom Health Connectu"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dan"</string> diff --git a/apk/res/values-hu/strings.xml b/apk/res/values-hu/strings.xml index 39ead841..779b4bfb 100644 --- a/apk/res/values-hu/strings.xml +++ b/apk/res/values-hu/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Nincs"</string> <string name="entry_details_title" msgid="590184849040247850">"Bejegyzés részletei"</string> <string name="backup_title" msgid="211503191266235085">"Biztonsági mentés"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Adatforrások és prioritás"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Adatforrások és prioritás"</string> <string name="set_units_title" msgid="2657822539603758029">"Egységek beállítása"</string> <string name="recent_access_header" msgid="7623497371790225888">"Legutóbbi hozzáférés"</string> <string name="no_recent_access" msgid="4724297929902441784">"Egyetlen alkalmazás sem fért hozzá a Health Connect szolgáltatáshoz a közelmúltban"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Hogyan működnek a források és a priorizálás?"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Alkalmazás hozzáadása"</string> <string name="edit_data_sources" msgid="79641360876849547">"Alkalmazásforrások szerkesztése"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Alapértelmezett"</string> <string name="app_data_title" msgid="6499967982291000837">"Alkalmazásadatok"</string> <string name="no_data_footer" msgid="4777297654713673100">"Itt jelennek meg az adatok azokból az alkalmazásokból, amelyeknek hozzáférése van a Health Connecthez"</string> <string name="date_picker_day" msgid="3076687507968958991">"Nap"</string> diff --git a/apk/res/values-hy/strings.xml b/apk/res/values-hy/strings.xml index 36f53456..430268f8 100644 --- a/apk/res/values-hy/strings.xml +++ b/apk/res/values-hy/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ընտրված տարրեր չկան"</string> <string name="entry_details_title" msgid="590184849040247850">"Գրառման մանրամասները"</string> <string name="backup_title" msgid="211503191266235085">"Պահուստավորում"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Տվյալների աղբյուրներ և առաջնահերթություն"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Տվյալների աղբյուրներ և առաջնահերթություն"</string> <string name="set_units_title" msgid="2657822539603758029">"Կարգավորել չափման միավորները"</string> <string name="recent_access_header" msgid="7623497371790225888">"Վերջին օգտագործումը"</string> <string name="no_recent_access" msgid="4724297929902441784">"Վերջերս ոչ մի հավելված չի օգտագործել Health Connect-ը"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Ինչպես են աղբյուրներն ու առաջնահերթությունն աշխատում"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Ավելացնել հավելված"</string> <string name="edit_data_sources" msgid="79641360876849547">"Հավելվածների աղբյուրների փոփոխում"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Կանխադրված տարբերակ"</string> <string name="app_data_title" msgid="6499967982291000837">"Հավելվածի տվյալներ"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect-ին հասանելիություն ունեցող հավելվածների տվյալները կցուցադրվեն այստեղ"</string> <string name="date_picker_day" msgid="3076687507968958991">"Օր"</string> diff --git a/apk/res/values-in/strings.xml b/apk/res/values-in/strings.xml index 542362b9..62faa905 100644 --- a/apk/res/values-in/strings.xml +++ b/apk/res/values-in/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Tidak ada"</string> <string name="entry_details_title" msgid="590184849040247850">"Detail entri"</string> <string name="backup_title" msgid="211503191266235085">"Pencadangan"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Prioritas & sumber data"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Prioritas dan sumber data"</string> <string name="set_units_title" msgid="2657822539603758029">"Tetapkan unit"</string> <string name="recent_access_header" msgid="7623497371790225888">"Akses terbaru"</string> <string name="no_recent_access" msgid="4724297929902441784">"Tidak ada aplikasi yang baru-baru ini mengakses Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Cara kerja sumber & prioritas"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Tambahkan aplikasi"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edit sumber aplikasi"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Default perangkat"</string> <string name="app_data_title" msgid="6499967982291000837">"Data aplikasi"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data dari aplikasi yang memiliki akses ke Health Connect akan muncul di sini"</string> <string name="date_picker_day" msgid="3076687507968958991">"Hari"</string> diff --git a/apk/res/values-is/strings.xml b/apk/res/values-is/strings.xml index 9a2f7bf6..5b9bc2e3 100644 --- a/apk/res/values-is/strings.xml +++ b/apk/res/values-is/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ekkert"</string> <string name="entry_details_title" msgid="590184849040247850">"Upplýsingar um færslu"</string> <string name="backup_title" msgid="211503191266235085">"Öryggisafrit"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Gagnauppsprettur og forgangur"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Gagnauppsprettur og forgangur"</string> <string name="set_units_title" msgid="2657822539603758029">"Velja mælieiningar"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nýlegur aðgangur"</string> <string name="no_recent_access" msgid="4724297929902441784">"Engin forrit hafa fengið aðgang að Heilsutengingu nýlega"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Virkni uppruna og forgangs"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Bæta við forriti"</string> <string name="edit_data_sources" msgid="79641360876849547">"Bæta við eða fjarlægja forrit sem veita upplýsingar"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Sjálfgefin stilling tækis"</string> <string name="app_data_title" msgid="6499967982291000837">"Forritsgögn"</string> <string name="no_data_footer" msgid="4777297654713673100">"Gögn frá forritum með aðgang að Heilsutengingu birtast hér"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dagur"</string> diff --git a/apk/res/values-it/strings.xml b/apk/res/values-it/strings.xml index 278ce7b1..f3037bb9 100644 --- a/apk/res/values-it/strings.xml +++ b/apk/res/values-it/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Nessuna"</string> <string name="entry_details_title" msgid="590184849040247850">"Dettagli voce"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Origini di app e priorità"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Origini dati e priorità"</string> <string name="set_units_title" msgid="2657822539603758029">"Imposta unità"</string> <string name="recent_access_header" msgid="7623497371790225888">"Accesso recente"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nessuna app ha usato di recente Connessione Salute"</string> @@ -788,10 +788,9 @@ <string name="data_sources_empty_state" msgid="1899652759274805556">"Nessuna origine di app"</string> <string name="data_sources_empty_state_footer" msgid="8933950342291569638">"Dopo aver concesso all\'app le autorizzazioni di scrittura di dati <xliff:g id="CATEGORY_NAME">%1$s</xliff:g>, le origini verranno visualizzate qui."</string> <string name="data_sources_help_link" msgid="7740264923634947915">"Come funzionano le origini e la prioritizzazione"</string> - <string name="data_sources_add_app" msgid="319926596123692514">"Aggiungere un\'app"</string> + <string name="data_sources_add_app" msgid="319926596123692514">"Aggiungi un\'app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Modifica origini di app"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Opzione predefinita"</string> <string name="app_data_title" msgid="6499967982291000837">"Dati dell\'app"</string> <string name="no_data_footer" msgid="4777297654713673100">"I dati delle app con accesso a Connessione Salute appariranno qui"</string> <string name="date_picker_day" msgid="3076687507968958991">"Giorno"</string> diff --git a/apk/res/values-iw/strings.xml b/apk/res/values-iw/strings.xml index 98b12a25..98ba16dc 100644 --- a/apk/res/values-iw/strings.xml +++ b/apk/res/values-iw/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ללא"</string> <string name="entry_details_title" msgid="590184849040247850">"פרטי כניסה"</string> <string name="backup_title" msgid="211503191266235085">"גיבוי"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"מקורות הנתונים ועדיפות"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"מקורות הנתונים ועדיפות"</string> <string name="set_units_title" msgid="2657822539603758029">"הגדרת יחידות"</string> <string name="recent_access_header" msgid="7623497371790225888">"אפליקציות שניגשו לנתונים לאחרונה"</string> <string name="no_recent_access" msgid="4724297929902441784">"אף אפליקציה לא ניגשה לאחרונה ל-Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"איך מקורות הנתונים והעדיפות פועלים"</string> <string name="data_sources_add_app" msgid="319926596123692514">"הוספת אפליקציה"</string> <string name="edit_data_sources" msgid="79641360876849547">"עריכת המקורות של האפליקציות"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ברירת המחדל של המכשיר"</string> <string name="app_data_title" msgid="6499967982291000837">"נתוני האפליקציה"</string> <string name="no_data_footer" msgid="4777297654713673100">"כאן יוצגו נתונים מאפליקציות שיש להן גישה ל-Health Connect"</string> <string name="date_picker_day" msgid="3076687507968958991">"יום"</string> diff --git a/apk/res/values-ja/strings.xml b/apk/res/values-ja/strings.xml index 9d2aa6ad..632d21cc 100644 --- a/apk/res/values-ja/strings.xml +++ b/apk/res/values-ja/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"なし"</string> <string name="entry_details_title" msgid="590184849040247850">"エントリの詳細"</string> <string name="backup_title" msgid="211503191266235085">"バックアップ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"データソースと優先度"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"データソースと優先度"</string> <string name="set_units_title" msgid="2657822539603758029">"ユニットを設定"</string> <string name="recent_access_header" msgid="7623497371790225888">"最近のアクセス"</string> <string name="no_recent_access" msgid="4724297929902441784">"ヘルスコネクトに最近アクセスしたアプリはありません"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ソースと優先度の仕組み"</string> <string name="data_sources_add_app" msgid="319926596123692514">"アプリを追加"</string> <string name="edit_data_sources" msgid="79641360876849547">"アプリのソースを編集する"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"デバイスのデフォルト"</string> <string name="app_data_title" msgid="6499967982291000837">"アプリデータ"</string> <string name="no_data_footer" msgid="4777297654713673100">"ヘルスコネクトにアクセスできるアプリのデータがここに表示されます"</string> <string name="date_picker_day" msgid="3076687507968958991">"日"</string> diff --git a/apk/res/values-ka/strings.xml b/apk/res/values-ka/strings.xml index 81e50c90..a0f6aa8b 100644 --- a/apk/res/values-ka/strings.xml +++ b/apk/res/values-ka/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"არცერთი"</string> <string name="entry_details_title" msgid="590184849040247850">"შესვლის დეტალები"</string> <string name="backup_title" msgid="211503191266235085">"სარეზერვო ასლის შექმნა"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"მონაცემთა წყაროები და პრიორიტეტი"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"მონაცემთა წყაროები და პრიორიტეტები"</string> <string name="set_units_title" msgid="2657822539603758029">"ერთეულების დაყენება"</string> <string name="recent_access_header" msgid="7623497371790225888">"ბოლო წვდომა"</string> <string name="no_recent_access" msgid="4724297929902441784">"არცერთ აპს არ ჰქონდა წვდომა Health Connect-თან"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"როგორ მუშაობს წყაროები და პრიორიტეტები"</string> <string name="data_sources_add_app" msgid="319926596123692514">"დაამატეთ აპი"</string> <string name="edit_data_sources" msgid="79641360876849547">"აპების წყაროების რედაქტირება"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"მოწყობილობის ნაგულისხმევი"</string> <string name="app_data_title" msgid="6499967982291000837">"აპის მონაცემები"</string> <string name="no_data_footer" msgid="4777297654713673100">"მონაცემები იმ აპებიდან, რომლებსაც Health Connect-ზე აქვს წვდომა, აქ გამოჩნდება"</string> <string name="date_picker_day" msgid="3076687507968958991">"დღე"</string> diff --git a/apk/res/values-kk/strings.xml b/apk/res/values-kk/strings.xml index fadaa574..e23ad055 100644 --- a/apk/res/values-kk/strings.xml +++ b/apk/res/values-kk/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ешқандай"</string> <string name="entry_details_title" msgid="590184849040247850">"Енгізілген дерек мәліметтері"</string> <string name="backup_title" msgid="211503191266235085">"Сақтық көшірме жасау"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Дереккөздер және басымдық"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Дереккөздер және басымдық"</string> <string name="set_units_title" msgid="2657822539603758029">"Бірліктер орнату"</string> <string name="recent_access_header" msgid="7623497371790225888">"Соңғы пайдаланғандар"</string> <string name="no_recent_access" msgid="4724297929902441784">"Жақында ешбір қолданба Health Connect-ті пайдаланбады"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Дереккөздер мен басымдық туралы ақпарат"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Қолданба қосу"</string> <string name="edit_data_sources" msgid="79641360876849547">"Қолданба дереккөздерін өзгерту"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Құрылғының әдепкі қолданбасы"</string> <string name="app_data_title" msgid="6499967982291000837">"Қолданба деректері"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect-ке кіру рұқсаты бар қолданбалардан алынған дерек осы жерде көрсетіледі."</string> <string name="date_picker_day" msgid="3076687507968958991">"Күн"</string> diff --git a/apk/res/values-km/strings.xml b/apk/res/values-km/strings.xml index 4f325da2..d1663f4c 100644 --- a/apk/res/values-km/strings.xml +++ b/apk/res/values-km/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"គ្មាន"</string> <string name="entry_details_title" msgid="590184849040247850">"ព័ត៌មានលម្អិតអំពីធាតុ"</string> <string name="backup_title" msgid="211503191266235085">"បម្រុងទុក"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ប្រភពទិន្នន័យ និងអាទិភាព"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ប្រភពទិន្នន័យ និងអាទិភាព"</string> <string name="set_units_title" msgid="2657822539603758029">"កំណត់ឯកតា"</string> <string name="recent_access_header" msgid="7623497371790225888">"ការចូលប្រើថ្មីៗ"</string> <string name="no_recent_access" msgid="4724297929902441784">"មិនមានកម្មវិធីណាមួយបានចូលប្រើ Health Connect ថ្មីៗនេះទេ"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"របៀបដែលប្រភព និងការកំណត់អាទិភាពដំណើរការ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"បញ្ចូលកម្មវិធី"</string> <string name="edit_data_sources" msgid="79641360876849547">"កែប្រភពកម្មវិធី"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"លំនាំដើមរបស់ឧបករណ៍"</string> <string name="app_data_title" msgid="6499967982291000837">"ទិន្នន័យកម្មវិធី"</string> <string name="no_data_footer" msgid="4777297654713673100">"ទិន្នន័យពីកម្មវិធីដែលមានសិទ្ធិចូលប្រើ Health Connect នឹងបង្ហាញនៅទីនេះ"</string> <string name="date_picker_day" msgid="3076687507968958991">"ថ្ងៃ"</string> diff --git a/apk/res/values-kn/strings.xml b/apk/res/values-kn/strings.xml index faf64259..1fd17cda 100644 --- a/apk/res/values-kn/strings.xml +++ b/apk/res/values-kn/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ಯಾವುದೂ ಅಲ್ಲ"</string> <string name="entry_details_title" msgid="590184849040247850">"ಪ್ರವೇಶದ ವಿವರಗಳು"</string> <string name="backup_title" msgid="211503191266235085">"ಬ್ಯಾಕಪ್"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ಡೇಟಾ ಮೂಲಗಳು ಮತ್ತು ಆದ್ಯತೆ"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ಡೇಟಾ ಮೂಲಗಳು ಮತ್ತು ಆದ್ಯತೆ"</string> <string name="set_units_title" msgid="2657822539603758029">"ಯೂನಿಟ್ಗಳನ್ನು ಸೆಟ್ ಮಾಡಿ"</string> <string name="recent_access_header" msgid="7623497371790225888">"ಇತ್ತೀಚಿನ ಆ್ಯಕ್ಸೆಸ್"</string> <string name="no_recent_access" msgid="4724297929902441784">"ಯಾವುದೇ ಆ್ಯಪ್ಗಳು ಇತ್ತೀಚೆಗೆ Health Connect ಅನ್ನು ಆ್ಯಕ್ಸೆಸ್ ಮಾಡಿಲ್ಲ"</string> @@ -704,7 +704,7 @@ <string name="deletion_started_done_button" msgid="1232018689825054257">"ಮುಗಿದಿದೆ"</string> <string name="priority_dialog_title" msgid="7360654442596118085">"ಆ್ಯಪ್ ಆದ್ಯತೆಯನ್ನು ಸೆಟ್ ಮಾಡಿ"</string> <string name="priority_dialog_message" msgid="6971250365335018184">"ಒಂದಕ್ಕಿಂತ ಹೆಚ್ಚಿನ ಆ್ಯಪ್ <xliff:g id="DATA_TYPE">%s</xliff:g> ಡೇಟಾವನ್ನು ಸೇರಿಸಿದರೆ, ಈ ಪಟ್ಟಿಯ ಮೇಲ್ಭಾಗದಲ್ಲಿರುವ ಆ್ಯಪ್ಗೆ Health Connect ಆದ್ಯತೆ ನೀಡುತ್ತದೆ. ಆ್ಯಪ್ಗಳನ್ನು ಮರುಕ್ರಮಗೊಳಿಸಲು ಅವುಗಳನ್ನು ಡ್ರ್ಯಾಗ್ ಮಾಡಿ."</string> - <string name="priority_dialog_positive_button" msgid="2503570694373675092">"ಉಳಿಸಿ"</string> + <string name="priority_dialog_positive_button" msgid="2503570694373675092">"ಸೇವ್ ಮಾಡಿ"</string> <string name="action_drag_label_move_up" msgid="4221641798253080966">"ಮೇಲಕ್ಕೆ ಸರಿಸಿ"</string> <string name="action_drag_label_move_down" msgid="3448000958912947588">"ಕೆಳಕ್ಕೆ ಸರಿಸಿ"</string> <string name="action_drag_label_move_top" msgid="5114033774108663548">"ಮೇಲಕ್ಕೆ ಸರಿಸಿ"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ಮೂಲಗಳು ಮತ್ತು ಆದ್ಯತೆ ನೀಡುವಿಕೆ ಹೇಗೆ ಕಾರ್ಯನಿರ್ವಹಿಸುತ್ತವೆ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ಆ್ಯಪ್ ಒಂದನ್ನು ಸೇರಿಸಿ"</string> <string name="edit_data_sources" msgid="79641360876849547">"ಆ್ಯಪ್ ಮೂಲಗಳನ್ನು ಎಡಿಟ್ ಮಾಡಿ"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ಸಾಧನದ ಡೀಫಾಲ್ಟ್"</string> <string name="app_data_title" msgid="6499967982291000837">"ಆ್ಯಪ್ ಡೇಟಾ"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect ಗೆ ಆ್ಯಕ್ಸೆಸ್ ಹೊಂದಿರುವ ಆ್ಯಪ್ಗಳ ಡೇಟಾವನ್ನು ಇಲ್ಲಿ ತೋರಿಸಲಾಗುತ್ತದೆ"</string> <string name="date_picker_day" msgid="3076687507968958991">"ದಿನ"</string> diff --git a/apk/res/values-ko/strings.xml b/apk/res/values-ko/strings.xml index 3d53352e..84ed660b 100644 --- a/apk/res/values-ko/strings.xml +++ b/apk/res/values-ko/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"없음"</string> <string name="entry_details_title" msgid="590184849040247850">"항목 세부정보"</string> <string name="backup_title" msgid="211503191266235085">"백업"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"데이터 소스 및 우선순위"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"데이터 소스 및 우선순위"</string> <string name="set_units_title" msgid="2657822539603758029">"단위 설정"</string> <string name="recent_access_header" msgid="7623497371790225888">"최근 데이터에 액세스한 앱"</string> <string name="no_recent_access" msgid="4724297929902441784">"최근에 헬스 커넥트를 사용한 앱이 없음"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"소스 및 우선순위 지정의 작동 방식"</string> <string name="data_sources_add_app" msgid="319926596123692514">"앱 추가하기"</string> <string name="edit_data_sources" msgid="79641360876849547">"앱 소스 수정"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"기기 기본값"</string> <string name="app_data_title" msgid="6499967982291000837">"앱 데이터"</string> <string name="no_data_footer" msgid="4777297654713673100">"헬스 커넥트에 액세스할 수 있는 앱의 데이터가 여기에 표시됩니다."</string> <string name="date_picker_day" msgid="3076687507968958991">"일"</string> diff --git a/apk/res/values-ky/strings.xml b/apk/res/values-ky/strings.xml index 77e6b597..dde2c444 100644 --- a/apk/res/values-ky/strings.xml +++ b/apk/res/values-ky/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Жок"</string> <string name="entry_details_title" msgid="590184849040247850">"Киргизилген маалыматтын чоо-жайы"</string> <string name="backup_title" msgid="211503191266235085">"Камдык көчүрмөнү сактоо"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Маалымат булактары жана маанилүүлүгү"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Маалымат булактары жана маанилүүлүгү"</string> <string name="set_units_title" msgid="2657822539603758029">"Бирдиктерди тууралоо"</string> <string name="recent_access_header" msgid="7623497371790225888">"Соңку колдонмолор"</string> <string name="no_recent_access" msgid="4724297929902441784">"Health Connect\'ти бир да колдонмо пайдаланган жок"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Булактар жана маанилүүлүк кандайча иштейт"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Колдонмо кошуу"</string> <string name="edit_data_sources" msgid="79641360876849547">"Колдонмо булактарын түзөтүү"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Түзмөктүн демейки параметри"</string> <string name="app_data_title" msgid="6499967982291000837">"Колдонмонун дайындары"</string> <string name="no_data_footer" msgid="4777297654713673100">"Колдонмолордогу Health Connect кызматына байланыштуу нерселер ушул жерде көрүнөт"</string> <string name="date_picker_day" msgid="3076687507968958991">"Күн"</string> diff --git a/apk/res/values-lo/strings.xml b/apk/res/values-lo/strings.xml index 9a859d30..b02202c3 100644 --- a/apk/res/values-lo/strings.xml +++ b/apk/res/values-lo/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ບໍ່ມີ"</string> <string name="entry_details_title" msgid="590184849040247850">"ລາຍລະອຽດລາຍການ"</string> <string name="backup_title" msgid="211503191266235085">"ສຳຮອງຂໍ້ມູນ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ແຫຼ່ງຂໍ້ມູນ ແລະ ຄວາມສຳຄັນ"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ແຫຼ່ງຂໍ້ມູນ ແລະ ລຳດັບຄວາມສຳຄັນ"</string> <string name="set_units_title" msgid="2657822539603758029">"ຕັ້ງຫົວໜ່ວຍ"</string> <string name="recent_access_header" msgid="7623497371790225888">"ການເຂົ້າເຖິງຫຼ້າສຸດ"</string> <string name="no_recent_access" msgid="4724297929902441784">"ບໍ່ມີແອັບໃດເຂົ້າເຖິງ Health Connect ເມື່ອບໍ່ດົນມານີ້"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ແຫຼ່ງຂໍ້ມູນ ແລະ ການຈັດຄວາມສຳຄັນເຮັດວຽກແນວໃດ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ເພີ່ມແອັບ"</string> <string name="edit_data_sources" msgid="79641360876849547">"ແກ້ໄຂແຫຼ່ງທີ່ມາຂອງແອັບ"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ຄ່າເລີ່ມຕົ້ນອຸປະກອນ"</string> <string name="app_data_title" msgid="6499967982291000837">"ຂໍ້ມູນແອັບ"</string> <string name="no_data_footer" msgid="4777297654713673100">"ຂໍ້ມູນຈາກແອັບທີ່ມີສິດເຂົ້າເຖິງ Health Connect ຈະສະແດງຢູ່ບ່ອນນີ້"</string> <string name="date_picker_day" msgid="3076687507968958991">"ມື້"</string> diff --git a/apk/res/values-lt/strings.xml b/apk/res/values-lt/strings.xml index 2c9776e7..3e1d1770 100644 --- a/apk/res/values-lt/strings.xml +++ b/apk/res/values-lt/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Nėra"</string> <string name="entry_details_title" msgid="590184849040247850">"Išsami įvedimo informacija"</string> <string name="backup_title" msgid="211503191266235085">"Atsarginė kopija"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Duomenų šaltiniai ir prioritetas"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Duomenų šaltiniai ir prioritetas"</string> <string name="set_units_title" msgid="2657822539603758029">"Žr. vienetus"</string> <string name="recent_access_header" msgid="7623497371790225888">"Naujausia prieiga"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nėra neseniai „Health Connect“ pasiekusių programų"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kaip veikia šaltiniai ir prioritetų nustatymas"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Programos pridėjimas"</string> <string name="edit_data_sources" msgid="79641360876849547">"Redaguoti programų šaltinius"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Numatytasis įrenginio nustatymas"</string> <string name="app_data_title" msgid="6499967982291000837">"Programos duomenys"</string> <string name="no_data_footer" msgid="4777297654713673100">"Čia bus rodomi programų, turinčių prieigą prie „Health Connect“, duomenys"</string> <string name="date_picker_day" msgid="3076687507968958991">"Diena"</string> diff --git a/apk/res/values-lv/strings.xml b/apk/res/values-lv/strings.xml index 887808e3..a310af9e 100644 --- a/apk/res/values-lv/strings.xml +++ b/apk/res/values-lv/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Nav"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalizēta informācija par ierakstu"</string> <string name="backup_title" msgid="211503191266235085">"Dublēšana"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datu avoti un prioritāte"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datu avoti un prioritāte"</string> <string name="set_units_title" msgid="2657822539603758029">"Iestatīt vienības"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nesena piekļuve"</string> <string name="no_recent_access" msgid="4724297929902441784">"Neviena lietotne pēdējā laikā nav piekļuvusi platformai Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Avotu un prioritātes noteikšanas principi"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Pievienot lietotni"</string> <string name="edit_data_sources" msgid="79641360876849547">"Rediģēt lietotņu avotus"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Ierīces noklusējuma lietotne"</string> <string name="app_data_title" msgid="6499967982291000837">"Lietotnes dati"</string> <string name="no_data_footer" msgid="4777297654713673100">"Šeit būs redzami dati no lietotnēm, kurām ir piekļuve platformai Health Connect."</string> <string name="date_picker_day" msgid="3076687507968958991">"Diena"</string> diff --git a/apk/res/values-mk/strings.xml b/apk/res/values-mk/strings.xml index 30e6a2ba..f0a5285a 100644 --- a/apk/res/values-mk/strings.xml +++ b/apk/res/values-mk/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Нема"</string> <string name="entry_details_title" msgid="590184849040247850">"Детали на записот"</string> <string name="backup_title" msgid="211503191266235085">"Бекап"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Извори и приоритет на податоци"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Приоритет и извори на податоци"</string> <string name="set_units_title" msgid="2657822539603758029">"Поставете единици"</string> <string name="recent_access_header" msgid="7623497371790225888">"Најнов пристап"</string> <string name="no_recent_access" msgid="4724297929902441784">"Нема апликации што неодамна пристапиле до Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Како функционираат изворите и приоритизацијата"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Додајте апликација"</string> <string name="edit_data_sources" msgid="79641360876849547">"Изменување на изворите на апликации"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Стандардни поставки за уредот"</string> <string name="app_data_title" msgid="6499967982291000837">"Податоци од апликација"</string> <string name="no_data_footer" msgid="4777297654713673100">"Податоците од апликациите со пристап до Health Connect ќе се прикажат тука"</string> <string name="date_picker_day" msgid="3076687507968958991">"Ден"</string> diff --git a/apk/res/values-ml/strings.xml b/apk/res/values-ml/strings.xml index f6886d76..f14e98c5 100644 --- a/apk/res/values-ml/strings.xml +++ b/apk/res/values-ml/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ഒന്നുമില്ല"</string> <string name="entry_details_title" msgid="590184849040247850">"എൻട്രിയുടെ വിശദാംശങ്ങൾ"</string> <string name="backup_title" msgid="211503191266235085">"ബാക്കപ്പ് ചെയ്യുക"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ഡാറ്റാ ഉറവിടങ്ങളും മുൻഗണനയും"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ഡാറ്റ ഉറവിടങ്ങളും മുൻഗണനയും"</string> <string name="set_units_title" msgid="2657822539603758029">"യൂണിറ്റുകൾ സജ്ജീകരിക്കുക"</string> <string name="recent_access_header" msgid="7623497371790225888">"അടുത്തിടെയുള്ള ആക്സസ്"</string> <string name="no_recent_access" msgid="4724297929902441784">"അടുത്തിടെ Health Connect ആക്സസ് ചെയ്ത ആപ്പുകളൊന്നുമില്ല"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ഉറവിടങ്ങളും മുൻഗണനകളും എങ്ങനെ പ്രവർത്തിക്കുന്നു"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ആപ്പ് ചേർക്കുക"</string> <string name="edit_data_sources" msgid="79641360876849547">"ആപ്പ് ഉറവിടങ്ങൾ എഡിറ്റ് ചെയ്യുക"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ഉപകരണത്തിൽ ഡിഫോൾട്ടായുള്ളത്"</string> <string name="app_data_title" msgid="6499967982291000837">"ആപ്പ് ഡാറ്റ"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect-ലേക്ക് ആക്സസ് ഉള്ള ആപ്പുകളിൽ നിന്നുള്ള ഡാറ്റ ഇവിടെ കാണിക്കും"</string> <string name="date_picker_day" msgid="3076687507968958991">"ദിവസം"</string> diff --git a/apk/res/values-mn/strings.xml b/apk/res/values-mn/strings.xml index 6f77bbbf..f6b01b27 100644 --- a/apk/res/values-mn/strings.xml +++ b/apk/res/values-mn/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Байхгүй"</string> <string name="entry_details_title" msgid="590184849040247850">"Орсон мэдээллийн дэлгэрэнгүй"</string> <string name="backup_title" msgid="211503191266235085">"Нөөцлөх"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Дата сурвалж болон чухалчлал"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Дата сурвалж болон чухалчлал"</string> <string name="set_units_title" msgid="2657822539603758029">"Нэгжүүд тохируулах"</string> <string name="recent_access_header" msgid="7623497371790225888">"Саяхны хандалт"</string> <string name="no_recent_access" msgid="4724297929902441784">"Сүүлийн үед ямар ч апп Health Connect-д хандаагүй байна"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Эх сурвалж болон чухалчлал хэрхэн ажилладаг вэ?"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Апп нэмэх"</string> <string name="edit_data_sources" msgid="79641360876849547">"Аппын эх сурвалжуудыг засах"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Төхөөрөмжийн өгөгдмөл"</string> <string name="app_data_title" msgid="6499967982291000837">"Aппын өгөгдөл"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect-д хандах эрхтэй аппуудын өгөгдлийг энд харуулна"</string> <string name="date_picker_day" msgid="3076687507968958991">"Өдөр"</string> diff --git a/apk/res/values-mr/strings.xml b/apk/res/values-mr/strings.xml index 689f5cd3..ad0b2476 100644 --- a/apk/res/values-mr/strings.xml +++ b/apk/res/values-mr/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"काहीही नाही"</string> <string name="entry_details_title" msgid="590184849040247850">"एंट्रीचे तपशील"</string> <string name="backup_title" msgid="211503191266235085">"बॅकअप"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"डेटा स्रोत & प्राधान्य"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"डेटा स्रोत आणि प्राधान्य"</string> <string name="set_units_title" msgid="2657822539603758029">"युनिट सेट करा"</string> <string name="recent_access_header" msgid="7623497371790225888">"अलीकडील अॅक्सेस"</string> <string name="no_recent_access" msgid="4724297929902441784">"अलीकडे कोणत्याही अॅप्सनी Health Connect अॅक्सेस केले नाही"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"स्रोत & प्राधान्य देणे कसे काम करते"</string> <string name="data_sources_add_app" msgid="319926596123692514">"एखादे ॲप जोडा"</string> <string name="edit_data_sources" msgid="79641360876849547">"अॅप स्रोत संपादित करा"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"डिव्हाइस डीफॉल्ट"</string> <string name="app_data_title" msgid="6499967982291000837">"अॅप डेटा"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect चा अॅक्सेस असलेल्या ॲप्सचा डेटा येथे दर्शवला जाईल"</string> <string name="date_picker_day" msgid="3076687507968958991">"दिवस"</string> diff --git a/apk/res/values-ms/strings.xml b/apk/res/values-ms/strings.xml index 1a25b38a..61011786 100644 --- a/apk/res/values-ms/strings.xml +++ b/apk/res/values-ms/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Tiada"</string> <string name="entry_details_title" msgid="590184849040247850">"Butiran kemasukan"</string> <string name="backup_title" msgid="211503191266235085">"Sandaran"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Sumber & keutamaan data"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Sumber dan keutamaan data"</string> <string name="set_units_title" msgid="2657822539603758029">"Tetapkan unit"</string> <string name="recent_access_header" msgid="7623497371790225888">"Akses terbaharu"</string> <string name="no_recent_access" msgid="4724297929902441784">"Tiada apl yang mengakses Health Connect baru-baru ini"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Cara sumber & keutamaan berfungsi"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Tambahkan apl"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edit sumber apl"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Lalai peranti"</string> <string name="app_data_title" msgid="6499967982291000837">"Data apl"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data daripada apl dengan akses kepada Health Connect akan dipaparkan di sini"</string> <string name="date_picker_day" msgid="3076687507968958991">"Hari"</string> diff --git a/apk/res/values-my/strings.xml b/apk/res/values-my/strings.xml index be02b49f..a3768708 100644 --- a/apk/res/values-my/strings.xml +++ b/apk/res/values-my/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"မရှိ"</string> <string name="entry_details_title" msgid="590184849040247850">"ထည့်သွင်းမှု အသေးစိတ်"</string> <string name="backup_title" msgid="211503191266235085">"အရန်သိမ်းရန်"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ဒေတာရင်းမြစ်နှင့် ဦးစားပေး"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ဒေတာရင်းမြစ်များနှင့် ဦးစားပေး"</string> <string name="set_units_title" msgid="2657822539603758029">"ယူနစ်သတ်မှတ်ရန်"</string> <string name="recent_access_header" msgid="7623497371790225888">"မကြာသေးမီက အသုံးပြုမှု"</string> <string name="no_recent_access" msgid="4724297929902441784">"မည်သည်အက်ပ်မျှ Health Connect ကို မကြာသေးမီကသုံးမထားပါ"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ရင်းမြစ်များနှင့် ဦးစားပေးခြင်း အလုပ်လုပ်ပုံ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"အက်ပ် ထည့်ရန်"</string> <string name="edit_data_sources" msgid="79641360876849547">"အက်ပ်ရင်းမြစ်များ တည်းဖြတ်ရန်"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"စက်ပစ္စည်းမူရင်း"</string> <string name="app_data_title" msgid="6499967982291000837">"အက်ပ်ဒေတာ"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect သုံးခွင့်ရှိသော အက်ပ်များမှ ဒေတာကို ဤနေရာတွင် ပြပါမည်"</string> <string name="date_picker_day" msgid="3076687507968958991">"ရက်"</string> diff --git a/apk/res/values-nb/strings.xml b/apk/res/values-nb/strings.xml index 6da6cca3..a0a2f160 100644 --- a/apk/res/values-nb/strings.xml +++ b/apk/res/values-nb/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ingen"</string> <string name="entry_details_title" msgid="590184849040247850">"Oppføringsdetaljer"</string> <string name="backup_title" msgid="211503191266235085">"Sikkerhetskopiering"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datakilder og prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datakilder og prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Angi enheter"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nylig tilgang"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ingen apper har nylig brukt Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Hvordan kilder og prioritering fungerer"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Legg til en app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Endre appkilder"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Standard for enheten"</string> <string name="app_data_title" msgid="6499967982291000837">"Appdata"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data fra apper med tilgang til Health Connect vises her"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dag"</string> diff --git a/apk/res/values-ne/strings.xml b/apk/res/values-ne/strings.xml index dd0e98ae..fb339510 100644 --- a/apk/res/values-ne/strings.xml +++ b/apk/res/values-ne/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"कुनै पनि एप कनेक्ट गरिएको छैन"</string> <string name="entry_details_title" msgid="590184849040247850">"इन्ट्रीसम्बन्धी विवरणहरू"</string> <string name="backup_title" msgid="211503191266235085">"ब्याकअप गर्नुहोस्"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"डेटाका स्रोत तथा प्राथमिकता"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"जानकारीका स्रोत तथा प्राथमिकता"</string> <string name="set_units_title" msgid="2657822539603758029">"एकाइ सेट गर्नुहोस्"</string> <string name="recent_access_header" msgid="7623497371790225888">"हालसालै हेर्ने र प्रयोग गर्ने एप"</string> <string name="no_recent_access" msgid="4724297929902441784">"कुनै पनि एपले हालसालै Health Connect प्रयोग गरेको छैन"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"स्रोत तथा प्राथमिकताले काम गर्ने तरिका"</string> <string name="data_sources_add_app" msgid="319926596123692514">"कुनै एप हाल्नुहोस्"</string> <string name="edit_data_sources" msgid="79641360876849547">"एपका स्रोत सम्पादन गर्नुहोस्"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"डिफल्ट डिभाइस"</string> <string name="app_data_title" msgid="6499967982291000837">"एपसम्बन्धी डेटा"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect एक्सेस गर्न सक्ने एपहरूको डेटा यहाँ देखिने छ"</string> <string name="date_picker_day" msgid="3076687507968958991">"दिन"</string> diff --git a/apk/res/values-nl/strings.xml b/apk/res/values-nl/strings.xml index 8b539b27..388d1a8c 100644 --- a/apk/res/values-nl/strings.xml +++ b/apk/res/values-nl/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Geen"</string> <string name="entry_details_title" msgid="590184849040247850">"Invoerdetails"</string> <string name="backup_title" msgid="211503191266235085">"Back-up"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Gegevensbronnen en prioriteit"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Gegevensbronnen en prioriteit"</string> <string name="set_units_title" msgid="2657822539603758029">"Eenheden instellen"</string> <string name="recent_access_header" msgid="7623497371790225888">"Recente toegang"</string> <string name="no_recent_access" msgid="4724297929902441784">"Er zijn geen apps die recent toegang tot Health Connect hebben gehad"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Hoe bronnen en prioritering werken"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Een app toevoegen"</string> <string name="edit_data_sources" msgid="79641360876849547">"App-bronnen bewerken"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Apparaatstandaard"</string> <string name="app_data_title" msgid="6499967982291000837">"App-gegevens"</string> <string name="no_data_footer" msgid="4777297654713673100">"Gegevens van apps met toegang tot Health Connect worden hier getoond"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dag"</string> diff --git a/apk/res/values-or/strings.xml b/apk/res/values-or/strings.xml index 67818ccf..d6fcc13b 100644 --- a/apk/res/values-or/strings.xml +++ b/apk/res/values-or/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"କିଛି ନାହିଁ"</string> <string name="entry_details_title" msgid="590184849040247850">"ଏଣ୍ଟ୍ରି ବିବରଣୀ"</string> <string name="backup_title" msgid="211503191266235085">"ବେକଅପ ନିଅନ୍ତୁ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ଡାଟା ସୋର୍ସ ଏବଂ ପ୍ରାଥମିକତା"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ଡାଟା ସୋର୍ସ ଏବଂ ପ୍ରାଥମିକତା"</string> <string name="set_units_title" msgid="2657822539603758029">"ୟୁନିଟ ସେଟ କରନ୍ତୁ"</string> <string name="recent_access_header" msgid="7623497371790225888">"ବର୍ତ୍ତମାନର ଆକ୍ସେସ"</string> <string name="no_recent_access" msgid="4724297929902441784">"କୌଣସି ଆପ୍ସ ବର୍ତ୍ତମାନ Health Connectକୁ ଆକ୍ସେସ କରିନାହିଁ"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ସୋର୍ସ ଏବଂ ପ୍ରାଥମିକତା କିପରି କାର୍ଯ୍ୟ କରେ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ଏକ ଆପ ଯୋଗ କରନ୍ତୁ"</string> <string name="edit_data_sources" msgid="79641360876849547">"ଆପ ସୋର୍ସଗୁଡ଼ିକୁ ଏଡିଟ କରନ୍ତୁ"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ଡିଭାଇସ୍ ଡିଫଲ୍ଟ"</string> <string name="app_data_title" msgid="6499967982291000837">"ଆପ ଡାଟା"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connectକୁ ଆକ୍ସେସ ଥିବା ଆପ୍ସରୁ ଡାଟା ଏଠାରେ ଦେଖାଯିବ"</string> <string name="date_picker_day" msgid="3076687507968958991">"ଦିନ"</string> diff --git a/apk/res/values-pa/strings.xml b/apk/res/values-pa/strings.xml index a290fd60..b37f4fdc 100644 --- a/apk/res/values-pa/strings.xml +++ b/apk/res/values-pa/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ਕੋਈ ਨਹੀਂ"</string> <string name="entry_details_title" msgid="590184849040247850">"ਐਂਟਰੀ ਸੰਬੰਧੀ ਵੇਰਵੇ"</string> <string name="backup_title" msgid="211503191266235085">"ਬੈਕਅੱਪ"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ਡਾਟਾ ਸਰੋਤ ਅਤੇ ਤਰਜੀਹ"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ਡਾਟਾ ਸਰੋਤ ਅਤੇ ਤਰਜੀਹ"</string> <string name="set_units_title" msgid="2657822539603758029">"ਇਕਾਈਆਂ ਸੈੱਟ ਕਰੋ"</string> <string name="recent_access_header" msgid="7623497371790225888">"ਹਾਲੀਆ ਪਹੁੰਚ"</string> <string name="no_recent_access" msgid="4724297929902441784">"ਹਾਲ ਹੀ ਵਿੱਚ ਕਿਸੇ ਵੀ ਐਪ ਨੇ Health Connect ਤੱਕ ਪਹੁੰਚ ਨਹੀਂ ਕੀਤੀ"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ਸਰੋਤ ਅਤੇ ਤਰਜੀਹੀਕਰਨ ਕਿਵੇਂ ਕੰਮ ਕਰਦੇ ਹਨ"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ਕੋਈ ਐਪ ਸ਼ਾਮਲ ਕਰੋ"</string> <string name="edit_data_sources" msgid="79641360876849547">"ਐਪ ਸਰੋਤਾਂ ਦਾ ਸੰਪਾਦਨ ਕਰੋ"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ਪੂਰਵ-ਨਿਰਧਾਰਿਤ ਡੀਵਾਈਸ"</string> <string name="app_data_title" msgid="6499967982291000837">"ਐਪ ਡਾਟਾ"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect ਤੱਕ ਪਹੁੰਚ ਵਾਲੀਆਂ ਐਪਾਂ ਦਾ ਡਾਟਾ ਇੱਥੇ ਦਿਖੇਗਾ"</string> <string name="date_picker_day" msgid="3076687507968958991">"ਦਿਨ"</string> diff --git a/apk/res/values-pl/strings.xml b/apk/res/values-pl/strings.xml index ba5c0a09..8b4eb92a 100644 --- a/apk/res/values-pl/strings.xml +++ b/apk/res/values-pl/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Brak"</string> <string name="entry_details_title" msgid="590184849040247850">"Szczegóły wpisu"</string> <string name="backup_title" msgid="211503191266235085">"Kopia zapasowa"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Źródła danych i priorytety"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Źródła danych i priorytet"</string> <string name="set_units_title" msgid="2657822539603758029">"Jednostki zestawu"</string> <string name="recent_access_header" msgid="7623497371790225888">"Ostatni dostęp"</string> <string name="no_recent_access" msgid="4724297929902441784">"Żadne aplikacje nie uzyskiwały ostatnio dostępu do Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Jak działają priorytety źródeł"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Dodaj aplikację"</string> <string name="edit_data_sources" msgid="79641360876849547">"Edytuj źródła aplikacji"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Ustawienie domyślne urządzenia"</string> <string name="app_data_title" msgid="6499967982291000837">"Dane aplikacji"</string> <string name="no_data_footer" msgid="4777297654713673100">"Tutaj pojawią się dane z aplikacji z dostępem do Health Connect"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dzień"</string> diff --git a/apk/res/values-pt-rPT/strings.xml b/apk/res/values-pt-rPT/strings.xml index 172192cc..94ad3881 100644 --- a/apk/res/values-pt-rPT/strings.xml +++ b/apk/res/values-pt-rPT/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Nenhuma"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalhes da entrada"</string> <string name="backup_title" msgid="211503191266235085">"Cópia de segurança"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Origens de dados e prioridade"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Origens de dados e prioridade"</string> <string name="set_units_title" msgid="2657822539603758029">"Definir unidades"</string> <string name="recent_access_header" msgid="7623497371790225888">"Acesso recente"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nenhuma app acedeu recentemente à Saúde Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Como funcionam as origens e a atribuição de prioridade"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Adicionar app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editar origens de apps"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Predefinição do dispositivo"</string> <string name="app_data_title" msgid="6499967982291000837">"Dados de apps"</string> <string name="no_data_footer" msgid="4777297654713673100">"Os dados de apps com acesso à Saúde Connect são apresentados aqui"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dia"</string> diff --git a/apk/res/values-pt/strings.xml b/apk/res/values-pt/strings.xml index d3f058e4..4403b81b 100644 --- a/apk/res/values-pt/strings.xml +++ b/apk/res/values-pt/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Nenhuma"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalhes da entrada"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Origens de dados e prioridade"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Fontes de dados e prioridade"</string> <string name="set_units_title" msgid="2657822539603758029">"Definir unidades"</string> <string name="recent_access_header" msgid="7623497371790225888">"Acessos recentes"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nenhum app acessou a plataforma Conexão Saúde recentemente"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Como origens e priorização funcionam"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Adicionar um app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editar fontes de apps"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Padrão do dispositivo"</string> <string name="app_data_title" msgid="6499967982291000837">"Dados do app"</string> <string name="no_data_footer" msgid="4777297654713673100">"Os dados de apps com acesso à Conexão Saúde vão aparecer aqui"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dia"</string> diff --git a/apk/res/values-ro/strings.xml b/apk/res/values-ro/strings.xml index c0fa680d..a6a6d22c 100644 --- a/apk/res/values-ro/strings.xml +++ b/apk/res/values-ro/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Niciuna"</string> <string name="entry_details_title" msgid="590184849040247850">"Detalii despre intrare"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Surse de date și prioritate"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Surse de date și prioritate"</string> <string name="set_units_title" msgid="2657822539603758029">"Setează unitățile"</string> <string name="recent_access_header" msgid="7623497371790225888">"Acces recent"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nicio aplicație nu a accesat recent Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Cum funcționează sursele și stabilirea priorității"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Adaugă o aplicație"</string> <string name="edit_data_sources" msgid="79641360876849547">"Editează sursele de aplicații"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Prestabilită pentru dispozitiv"</string> <string name="app_data_title" msgid="6499967982291000837">"Datele aplicației"</string> <string name="no_data_footer" msgid="4777297654713673100">"Datele din aplicațiile cu acces la Health Connect se vor afișa aici"</string> <string name="date_picker_day" msgid="3076687507968958991">"Zi"</string> diff --git a/apk/res/values-ru/strings.xml b/apk/res/values-ru/strings.xml index 78214d6e..52a25245 100644 --- a/apk/res/values-ru/strings.xml +++ b/apk/res/values-ru/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Нет приложений"</string> <string name="entry_details_title" msgid="590184849040247850">"Сведения о записи"</string> <string name="backup_title" msgid="211503191266235085">"Резервное копирование"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Источники данных и приоритет"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Источники данных и приоритет"</string> <string name="set_units_title" msgid="2657822539603758029">"Настроить единицы измерения"</string> <string name="recent_access_header" msgid="7623497371790225888">"Доступ за последнее время"</string> <string name="no_recent_access" msgid="4724297929902441784">"В последнее время приложения не получали доступ к сервису \"Здоровье и спорт\""</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Сведения об источниках данных и приоритете"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Добавить приложение"</string> <string name="edit_data_sources" msgid="79641360876849547">"Изменить список приложений"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Приложение по умолчанию"</string> <string name="app_data_title" msgid="6499967982291000837">"Данные приложения"</string> <string name="no_data_footer" msgid="4777297654713673100">"Здесь будут появляться данные из приложений, у которых есть доступ к приложению \"Здоровье и спорт\"."</string> <string name="date_picker_day" msgid="3076687507968958991">"День"</string> diff --git a/apk/res/values-si/strings.xml b/apk/res/values-si/strings.xml index bf0429b8..55833d66 100644 --- a/apk/res/values-si/strings.xml +++ b/apk/res/values-si/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"කිසිවක් නැත"</string> <string name="entry_details_title" msgid="590184849040247850">"ඇතුළත් කිරීමේ විස්තර"</string> <string name="backup_title" msgid="211503191266235085">"උපස්ථය"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"දත්ත මූලාශ්ර සහ ප්රමුඛතාවය"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"දත්ත මූලාශ්ර සහ ප්රමුඛතාවය"</string> <string name="set_units_title" msgid="2657822539603758029">"ඒකක සකසන්න"</string> <string name="recent_access_header" msgid="7623497371790225888">"මෑත ප්රවේශය"</string> <string name="no_recent_access" msgid="4724297929902441784">"Health Connect වෙත මෑතදී ප්රවේශ වූ යෙදුම් කිසිවක් නැත"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"මූලාශ්ර සහ ප්රමුඛතා දීම ක්රියා කරන ආකාරය"</string> <string name="data_sources_add_app" msgid="319926596123692514">"යෙදුමක් එක් කරන්න"</string> <string name="edit_data_sources" msgid="79641360876849547">"යෙදුම් මූලාශ්ර සංස්කරණය කරන්න"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"උපාංගයේ පෙරනිමිය"</string> <string name="app_data_title" msgid="6499967982291000837">"යෙදුම් දත්ත"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect වෙත ප්රවේශය ඇති යෙදුම් වෙතින් දත්ත මෙහි පෙන්වනු ඇත"</string> <string name="date_picker_day" msgid="3076687507968958991">"දිනය"</string> diff --git a/apk/res/values-sk/strings.xml b/apk/res/values-sk/strings.xml index 0d853fcc..5df59a08 100644 --- a/apk/res/values-sk/strings.xml +++ b/apk/res/values-sk/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Žiadne"</string> <string name="entry_details_title" msgid="590184849040247850">"Podrobnosti o zázname"</string> <string name="backup_title" msgid="211503191266235085">"Záloha"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Zdroje údajov a priorita"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Zdroje údajov a priorita"</string> <string name="set_units_title" msgid="2657822539603758029">"Nastaviť jednotky"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nedávny prístup"</string> <string name="no_recent_access" msgid="4724297929902441784">"Dáta o zdraví v poslednom čase nepoužili žiadne aplikácie"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Ako fungujú zdroje a priorizácia"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Pridať aplikáciu"</string> <string name="edit_data_sources" msgid="79641360876849547">"Upraviť zdroje aplikácií"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Predvolená aplikácia zariadenia"</string> <string name="app_data_title" msgid="6499967982291000837">"Dáta aplikácie"</string> <string name="no_data_footer" msgid="4777297654713673100">"Údaje z aplikácií s prístupom k Dátam o zdraví sa budú zobrazovať tu"</string> <string name="date_picker_day" msgid="3076687507968958991">"Deň"</string> diff --git a/apk/res/values-sl/strings.xml b/apk/res/values-sl/strings.xml index b5541bc8..2a97c482 100644 --- a/apk/res/values-sl/strings.xml +++ b/apk/res/values-sl/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Brez"</string> <string name="entry_details_title" msgid="590184849040247850">"Podrobnosti vnosa"</string> <string name="backup_title" msgid="211503191266235085">"Varnostno kopiranje"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Podatkovni viri in prednost"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Podatkovni viri in prednost"</string> <string name="set_units_title" msgid="2657822539603758029">"Nastavitev enot"</string> <string name="recent_access_header" msgid="7623497371790225888">"Nedavni dostop"</string> <string name="no_recent_access" msgid="4724297929902441784">"Nobena aplikacija ni nedavno dostopala do storitve Health Connect."</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kako delujejo viri in dodeljevanje prednosti"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Dodajanje aplikacije"</string> <string name="edit_data_sources" msgid="79641360876849547">"Urejanje virov aplikacij"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Privzeta aplikacija v napravi"</string> <string name="app_data_title" msgid="6499967982291000837">"Podatki aplikacije"</string> <string name="no_data_footer" msgid="4777297654713673100">"Podatki iz aplikacij z dostopom do aplikacije Health Connect bodo prikazani tukaj"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dan"</string> diff --git a/apk/res/values-sq/strings.xml b/apk/res/values-sq/strings.xml index 1ff1aa24..55f62b2e 100644 --- a/apk/res/values-sq/strings.xml +++ b/apk/res/values-sq/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Asnjë"</string> <string name="entry_details_title" msgid="590184849040247850">"Detajet e hyrjes"</string> <string name="backup_title" msgid="211503191266235085">"Rezervimi"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Burimet e të dhënave dhe përparësia"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Burimet e të dhënave dhe përparësia"</string> <string name="set_units_title" msgid="2657822539603758029">"Cakto njësitë"</string> <string name="recent_access_header" msgid="7623497371790225888">"Qasja së fundi"</string> <string name="no_recent_access" msgid="4724297929902441784">"Asnjë aplikacion nuk ka pasur qasje së fundi te Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Si funksionojnë burimet dhe dhënia e përparësisë"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Shto një aplikacion"</string> <string name="edit_data_sources" msgid="79641360876849547">"Modifiko burimet e aplikacioneve"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Parazgjedhja e pajisjes"</string> <string name="app_data_title" msgid="6499967982291000837">"Të dhënat e aplikacionit"</string> <string name="no_data_footer" msgid="4777297654713673100">"Të dhënat nga aplikacionet që kanë qasje te Health Connect do të shfaqen këtu"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dita"</string> diff --git a/apk/res/values-sr/strings.xml b/apk/res/values-sr/strings.xml index 0fa793f1..6434c73d 100644 --- a/apk/res/values-sr/strings.xml +++ b/apk/res/values-sr/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Ништа"</string> <string name="entry_details_title" msgid="590184849040247850">"Детаљи уноса"</string> <string name="backup_title" msgid="211503191266235085">"Резервна копија"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Извори података и приоритет"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Извори података и приоритет"</string> <string name="set_units_title" msgid="2657822539603758029">"Подеси јединице"</string> <string name="recent_access_header" msgid="7623497371790225888">"Недавни приступ"</string> <string name="no_recent_access" msgid="4724297929902441784">"Ниједна апликација није недавно приступила Повезивању здравља"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Како извори и одређивање приоритета раде"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Додај апликацију"</string> <string name="edit_data_sources" msgid="79641360876849547">"Измени изворе апликација"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Подразумевано за уређај"</string> <string name="app_data_title" msgid="6499967982291000837">"Подаци апликација"</string> <string name="no_data_footer" msgid="4777297654713673100">"Подаци из апликација са приступом Повезивању здравља приказаће се овде"</string> <string name="date_picker_day" msgid="3076687507968958991">"Дан"</string> diff --git a/apk/res/values-sv/strings.xml b/apk/res/values-sv/strings.xml index 93e77b06..ed0718db 100644 --- a/apk/res/values-sv/strings.xml +++ b/apk/res/values-sv/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Inga"</string> <string name="entry_details_title" msgid="590184849040247850">"Information om dataposten"</string> <string name="backup_title" msgid="211503191266235085">"Säkerhetskopiering"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Datakällor och -prioritet"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Datakällor och prioritet"</string> <string name="set_units_title" msgid="2657822539603758029">"Ställ in enheter"</string> <string name="recent_access_header" msgid="7623497371790225888">"Senaste åtkomst"</string> <string name="no_recent_access" msgid="4724297929902441784">"Inga appar har nyligen kommit åt Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Så fungerar källor och prioritering"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Lägg till en app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Redigera appkällor"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Enhetens standardinställning"</string> <string name="app_data_title" msgid="6499967982291000837">"Appdata"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data från appar med åtkomst till Health Connect visas här"</string> <string name="date_picker_day" msgid="3076687507968958991">"Dag"</string> diff --git a/apk/res/values-sw/strings.xml b/apk/res/values-sw/strings.xml index 4eace2a3..81532f8e 100644 --- a/apk/res/values-sw/strings.xml +++ b/apk/res/values-sw/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Hamna"</string> <string name="entry_details_title" msgid="590184849040247850">"Maelezo ya kipengee"</string> <string name="backup_title" msgid="211503191266235085">"Hifadhi nakala"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Vyanzo vya data na kipaumbele"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Vyanzo vya data na kipaumbele"</string> <string name="set_units_title" msgid="2657822539603758029">"Weka vipimo"</string> <string name="recent_access_header" msgid="7623497371790225888">"Ufikiaji wa hivi karibuni"</string> <string name="no_recent_access" msgid="4724297929902441784">"Hakuna programu zilizofikia Health Connect hivi karibuni"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Jinsi vyanzo na uwekaji kipaumbele vinavyofanya kazi"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Ongeza programu"</string> <string name="edit_data_sources" msgid="79641360876849547">"Badilisha vyanzo vya programu"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Hali chaguomsingi ya kifaa"</string> <string name="app_data_title" msgid="6499967982291000837">"Data ya programu"</string> <string name="no_data_footer" msgid="4777297654713673100">"Data kutoka kwa programu zinazofikia Health Connect itaonekana hapa"</string> <string name="date_picker_day" msgid="3076687507968958991">"Siku"</string> diff --git a/apk/res/values-ta/strings.xml b/apk/res/values-ta/strings.xml index 2178f2cf..2b3298a9 100644 --- a/apk/res/values-ta/strings.xml +++ b/apk/res/values-ta/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ஏதுமில்லை"</string> <string name="entry_details_title" msgid="590184849040247850">"உள்ளீட்டு விவரங்கள்"</string> <string name="backup_title" msgid="211503191266235085">"காப்புப் பிரதி எடு"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"தரவு மூலங்களும் முன்னுரிமையும்"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"தரவு மூலங்களும் முன்னுரிமையும்"</string> <string name="set_units_title" msgid="2657822539603758029">"அலகுகளை அமை"</string> <string name="recent_access_header" msgid="7623497371790225888">"சமீபத்திய அணுகல்"</string> <string name="no_recent_access" msgid="4724297929902441784">"சமீபத்தில் Health Connect ஆப்ஸை எந்த ஆப்ஸும் அணுகவில்லை"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"மூலங்களும் முன்னுரிமையும் செயல்படும் விதம்"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ஆப்ஸைச் சேர்த்தல்"</string> <string name="edit_data_sources" msgid="79641360876849547">"ஆப்ஸ் ஆதாரங்களை மாற்றுதல்"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"சாதனத்தின் இயல்புநிலை"</string> <string name="app_data_title" msgid="6499967982291000837">"ஆப்ஸ் தரவு"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connectடுக்கான அணுகல் உள்ள ஆப்ஸின் தரவு இங்கே காட்டப்படும்"</string> <string name="date_picker_day" msgid="3076687507968958991">"நாள்"</string> diff --git a/apk/res/values-te/strings.xml b/apk/res/values-te/strings.xml index d458d77f..334d6966 100644 --- a/apk/res/values-te/strings.xml +++ b/apk/res/values-te/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ఏదీ లేదు"</string> <string name="entry_details_title" msgid="590184849040247850">"ఎంట్రీ వివరాలు"</string> <string name="backup_title" msgid="211503191266235085">"బ్యాకప్"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"డేటా సోర్స్లు & ప్రాధాన్యత"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"డేటా సోర్స్లు, ప్రాధాన్యత"</string> <string name="set_units_title" msgid="2657822539603758029">"యూనిట్లను సెట్ చేయండి"</string> <string name="recent_access_header" msgid="7623497371790225888">"ఇటీవలి యాక్సెస్"</string> <string name="no_recent_access" msgid="4724297929902441784">"యాప్లు ఏవీ ఇటీవల Health Connectను యాక్సెస్ చేయలేదు"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"సోర్స్లు & ప్రాధాన్యత ఎలా పని చేస్తాయి"</string> <string name="data_sources_add_app" msgid="319926596123692514">"యాప్ను జోడించండి"</string> <string name="edit_data_sources" msgid="79641360876849547">"యాప్ సోర్స్లను ఎడిట్ చేయండి"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"పరికరంలో ఆటోమేటిక్గా సెట్ చేయబడి ఉన్న యాప్"</string> <string name="app_data_title" msgid="6499967982291000837">"యాప్ డేటా"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connectకు యాక్సెస్ ఉన్న యాప్ల డేటా ఇక్కడ చూపబడుతుంది"</string> <string name="date_picker_day" msgid="3076687507968958991">"రోజు"</string> diff --git a/apk/res/values-th/strings.xml b/apk/res/values-th/strings.xml index 8546ad26..81ba9455 100644 --- a/apk/res/values-th/strings.xml +++ b/apk/res/values-th/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"ไม่มี"</string> <string name="entry_details_title" msgid="590184849040247850">"รายละเอียดรายการ"</string> <string name="backup_title" msgid="211503191266235085">"การสำรองข้อมูล"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"แหล่งข้อมูลและลำดับความสำคัญ"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"แหล่งข้อมูลและลำดับความสำคัญ"</string> <string name="set_units_title" msgid="2657822539603758029">"ตั้งค่าหน่วย"</string> <string name="recent_access_header" msgid="7623497371790225888">"การเข้าถึงล่าสุด"</string> <string name="no_recent_access" msgid="4724297929902441784">"ไม่มีแอปที่เข้าถึง Health Connect เมื่อเร็วๆ นี้"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"แหล่งที่มาและลำดับความสำคัญทำงานอย่างไร"</string> <string name="data_sources_add_app" msgid="319926596123692514">"เพิ่มแอป"</string> <string name="edit_data_sources" msgid="79641360876849547">"แก้ไขแหล่งที่มาของแอป"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"ค่าเริ่มต้นของอุปกรณ์"</string> <string name="app_data_title" msgid="6499967982291000837">"ข้อมูลแอป"</string> <string name="no_data_footer" msgid="4777297654713673100">"ข้อมูลจากแอปที่มีสิทธิ์เข้าถึง Health Connect จะแสดงที่นี่"</string> <string name="date_picker_day" msgid="3076687507968958991">"วัน"</string> diff --git a/apk/res/values-tl/strings.xml b/apk/res/values-tl/strings.xml index c079047e..fe3365c8 100644 --- a/apk/res/values-tl/strings.xml +++ b/apk/res/values-tl/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Wala"</string> <string name="entry_details_title" msgid="590184849040247850">"Mga detalye ng entry"</string> <string name="backup_title" msgid="211503191266235085">"Backup"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Mga data source at priyoridad"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Mga data source at priyoridad"</string> <string name="set_units_title" msgid="2657822539603758029">"Itakda ang mga unit"</string> <string name="recent_access_header" msgid="7623497371790225888">"Kamakailang na-access"</string> <string name="no_recent_access" msgid="4724297929902441784">"Walang app na kamakailangang nag-access ng Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Paano gumagana ang mga source at pagbibigay ng priyoridad"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Magdagdag ng app"</string> <string name="edit_data_sources" msgid="79641360876849547">"I-edit ang mga source ng app"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Default ng device"</string> <string name="app_data_title" msgid="6499967982291000837">"Data ng app"</string> <string name="no_data_footer" msgid="4777297654713673100">"Lalabas ang data mula sa mga app na may access sa Health Connect dito"</string> <string name="date_picker_day" msgid="3076687507968958991">"Araw"</string> diff --git a/apk/res/values-tr/strings.xml b/apk/res/values-tr/strings.xml index 81a8a6c3..20f61eab 100644 --- a/apk/res/values-tr/strings.xml +++ b/apk/res/values-tr/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Yok"</string> <string name="entry_details_title" msgid="590184849040247850">"Giriş ayrıntıları"</string> <string name="backup_title" msgid="211503191266235085">"Yedekle"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Veri kaynakları ve önceliği"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Veri kaynakları ve öncelik"</string> <string name="set_units_title" msgid="2657822539603758029">"Birimleri ayarla"</string> <string name="recent_access_header" msgid="7623497371790225888">"En son erişim"</string> <string name="no_recent_access" msgid="4724297929902441784">"Yakın zamanda hiçbir uygulama Health Connect\'e erişmedi"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Kaynaklar ve önceliklendirme nasıl çalışır?"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Uygulama ekle"</string> <string name="edit_data_sources" msgid="79641360876849547">"Uygulama kaynaklarını düzenleyin"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Cihaz varsayılanı"</string> <string name="app_data_title" msgid="6499967982291000837">"Uygulama verileri"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect\'e erişimi olan uygulamaların verileri burada görünecek"</string> <string name="date_picker_day" msgid="3076687507968958991">"Gün"</string> diff --git a/apk/res/values-uk/strings.xml b/apk/res/values-uk/strings.xml index ea9f422e..c9242156 100644 --- a/apk/res/values-uk/strings.xml +++ b/apk/res/values-uk/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Немає"</string> <string name="entry_details_title" msgid="590184849040247850">"Деталі запису"</string> <string name="backup_title" msgid="211503191266235085">"Резервне копіювання"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Джерела даних і пріоритет"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Джерела даних і пріоритет"</string> <string name="set_units_title" msgid="2657822539603758029">"Вибрати одиниці вимірювання"</string> <string name="recent_access_header" msgid="7623497371790225888">"Нещодавній доступ"</string> <string name="no_recent_access" msgid="4724297929902441784">"Немає додатків, які нещодавно використовували Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Як працюють джерела й пріоритети"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Вибрати додаток"</string> <string name="edit_data_sources" msgid="79641360876849547">"Змінити додатки-джерела"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"За умовчанням для пристрою"</string> <string name="app_data_title" msgid="6499967982291000837">"Дані додатка"</string> <string name="no_data_footer" msgid="4777297654713673100">"Тут відображатимуться дані з додатків, які мають доступ до Health Connect"</string> <string name="date_picker_day" msgid="3076687507968958991">"День"</string> diff --git a/apk/res/values-ur/strings.xml b/apk/res/values-ur/strings.xml index ef5596f2..fc635663 100644 --- a/apk/res/values-ur/strings.xml +++ b/apk/res/values-ur/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"کوئی نہیں"</string> <string name="entry_details_title" msgid="590184849040247850">"اندراج کی تفصیلات"</string> <string name="backup_title" msgid="211503191266235085">"بیک اپ لیں"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"ڈیٹا کے ذرائع اور ترجیح"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"ڈیٹا کا ماخذات اور ترجیح"</string> <string name="set_units_title" msgid="2657822539603758029">"یونٹس سیٹ کریں"</string> <string name="recent_access_header" msgid="7623497371790225888">"حالیہ رسائی"</string> <string name="no_recent_access" msgid="4724297929902441784">"فی الحال کسی بھی ایپ نے Health Connect تک رسائی حاصل نہیں کی"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"ذرائع اور ترجیح کیسے کام کرتے ہیں"</string> <string name="data_sources_add_app" msgid="319926596123692514">"ایپ شامل کریں"</string> <string name="edit_data_sources" msgid="79641360876849547">"ایپ کے مآخذ میں ترمیم کریں"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"آلہ ڈیفالٹ"</string> <string name="app_data_title" msgid="6499967982291000837">"ایپ کا ڈیٹا"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect تک رسائی والی ایپس کا ڈیٹا یہاں ظاہر ہوگا"</string> <string name="date_picker_day" msgid="3076687507968958991">"دن"</string> diff --git a/apk/res/values-uz/strings.xml b/apk/res/values-uz/strings.xml index d4c310e3..d331b7a6 100644 --- a/apk/res/values-uz/strings.xml +++ b/apk/res/values-uz/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Hech biri"</string> <string name="entry_details_title" msgid="590184849040247850">"Qayd tafsilotlari"</string> <string name="backup_title" msgid="211503191266235085">"Zaxiralash"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Axborot manbalari va tartiblanishi"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Axborot manbalari va tartiblanishi"</string> <string name="set_units_title" msgid="2657822539603758029">"Birliklarni belgilash"</string> <string name="recent_access_header" msgid="7623497371790225888">"Oxirgi kirish"</string> <string name="no_recent_access" msgid="4724297929902441784">"Yaqin orada hech qanday ilova Health Connect ilovasiga ulanmagan"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Manbalar qanday tartiblanishi haqida"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Ilova kiriting"</string> <string name="edit_data_sources" msgid="79641360876849547">"Ilova manbalarini tahrirlash"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Qurilma standarti"</string> <string name="app_data_title" msgid="6499967982291000837">"Ilovaga tegishli maʼlumotlar"</string> <string name="no_data_footer" msgid="4777297654713673100">"Health Connect xizmatiga ruxsati bor ilovalar axboroti shu yerda chiqadi"</string> <string name="date_picker_day" msgid="3076687507968958991">"Kun"</string> diff --git a/apk/res/values-vi/strings.xml b/apk/res/values-vi/strings.xml index 52ae93cb..0997d09c 100644 --- a/apk/res/values-vi/strings.xml +++ b/apk/res/values-vi/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Không có"</string> <string name="entry_details_title" msgid="590184849040247850">"Thông tin nhập"</string> <string name="backup_title" msgid="211503191266235085">"Sao lưu"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Nguồn dữ liệu và mức độ ưu tiên"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Nguồn dữ liệu và mức độ ưu tiên"</string> <string name="set_units_title" msgid="2657822539603758029">"Cài đặt đơn vị"</string> <string name="recent_access_header" msgid="7623497371790225888">"Lần truy cập gần đây"</string> <string name="no_recent_access" msgid="4724297929902441784">"Không có ứng dụng nào truy cập vào Health Connect gần đây"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Cách hoạt động của nguồn dữ liệu và mức độ ưu tiên"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Thêm ứng dụng"</string> <string name="edit_data_sources" msgid="79641360876849547">"Chỉnh sửa nguồn ứng dụng"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Theo chế độ cài đặt mặc định của thiết bị"</string> <string name="app_data_title" msgid="6499967982291000837">"Dữ liệu ứng dụng"</string> <string name="no_data_footer" msgid="4777297654713673100">"Dữ liệu của các ứng dụng có quyền truy cập vào Health Connect sẽ xuất hiện ở đây"</string> <string name="date_picker_day" msgid="3076687507968958991">"Ngày"</string> diff --git a/apk/res/values-zh-rCN/strings.xml b/apk/res/values-zh-rCN/strings.xml index 37366a6b..b78b8720 100644 --- a/apk/res/values-zh-rCN/strings.xml +++ b/apk/res/values-zh-rCN/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"无"</string> <string name="entry_details_title" msgid="590184849040247850">"详细数据"</string> <string name="backup_title" msgid="211503191266235085">"备份"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"数据源和优先级"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"数据源和优先级"</string> <string name="set_units_title" msgid="2657822539603758029">"设置单位"</string> <string name="recent_access_header" msgid="7623497371790225888">"近期数据访问情况"</string> <string name="no_recent_access" msgid="4724297929902441784">"最近没有任何应用访问 Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"来源和优先级的运作方式"</string> <string name="data_sources_add_app" msgid="319926596123692514">"添加应用"</string> <string name="edit_data_sources" msgid="79641360876849547">"编辑应用来源"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"设备默认设置"</string> <string name="app_data_title" msgid="6499967982291000837">"应用数据"</string> <string name="no_data_footer" msgid="4777297654713673100">"具有 Health Connect 访问权的应用中的数据将显示在此处"</string> <string name="date_picker_day" msgid="3076687507968958991">"日"</string> diff --git a/apk/res/values-zh-rHK/strings.xml b/apk/res/values-zh-rHK/strings.xml index 6f98a402..0fabfcdb 100644 --- a/apk/res/values-zh-rHK/strings.xml +++ b/apk/res/values-zh-rHK/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"無"</string> <string name="entry_details_title" msgid="590184849040247850">"項目詳情"</string> <string name="backup_title" msgid="211503191266235085">"備份"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"資料來源和優先次序"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"資料來源和優先次序"</string> <string name="set_units_title" msgid="2657822539603758029">"設定單位"</string> <string name="recent_access_header" msgid="7623497371790225888">"近期存取記錄"</string> <string name="no_recent_access" msgid="4724297929902441784">"最近沒有應用程式存取 Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"來源和優先次序的運作方式"</string> <string name="data_sources_add_app" msgid="319926596123692514">"新增應用程式"</string> <string name="edit_data_sources" msgid="79641360876849547">"編輯應用程式來源清單"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"裝置預設設定"</string> <string name="app_data_title" msgid="6499967982291000837">"應用程式資料"</string> <string name="no_data_footer" msgid="4777297654713673100">"有 Health Connect 存取權的應用程式所提供的資料會在這裡顯示"</string> <string name="date_picker_day" msgid="3076687507968958991">"日"</string> diff --git a/apk/res/values-zh-rTW/strings.xml b/apk/res/values-zh-rTW/strings.xml index 635dc081..4ba3892d 100644 --- a/apk/res/values-zh-rTW/strings.xml +++ b/apk/res/values-zh-rTW/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"無"</string> <string name="entry_details_title" msgid="590184849040247850">"項目詳細資料"</string> <string name="backup_title" msgid="211503191266235085">"備份"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"資料來源與優先順序"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"資料來源與優先順序"</string> <string name="set_units_title" msgid="2657822539603758029">"設定單位"</string> <string name="recent_access_header" msgid="7623497371790225888">"近期存取記錄"</string> <string name="no_recent_access" msgid="4724297929902441784">"最近沒有任何應用程式存取 Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"來源與優先順序的運作方式"</string> <string name="data_sources_add_app" msgid="319926596123692514">"新增應用程式"</string> <string name="edit_data_sources" msgid="79641360876849547">"編輯應用程式來源清單"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"裝置預設設定"</string> <string name="app_data_title" msgid="6499967982291000837">"應用程式資料"</string> <string name="no_data_footer" msgid="4777297654713673100">"如果資料是來自有權存取 Health Connect 的應用程式,就會顯示在這裡"</string> <string name="date_picker_day" msgid="3076687507968958991">"一天"</string> diff --git a/apk/res/values-zu/strings.xml b/apk/res/values-zu/strings.xml index bd9831fc..6b009199 100644 --- a/apk/res/values-zu/strings.xml +++ b/apk/res/values-zu/strings.xml @@ -33,7 +33,7 @@ <string name="connected_apps_button_no_permissions_subtitle" msgid="1651994862419752908">"Lutho"</string> <string name="entry_details_title" msgid="590184849040247850">"Imininingwane yokungena"</string> <string name="backup_title" msgid="211503191266235085">"Yenza isipele"</string> - <string name="data_sources_and_priority_title" msgid="3871795785418187899">"Imithombo yedatha nokubalulekile"</string> + <string name="data_sources_and_priority_title" msgid="2360222350913604558">"Imithombo yedatha kanye nokubalulekile"</string> <string name="set_units_title" msgid="2657822539603758029">"Setha amayunithi"</string> <string name="recent_access_header" msgid="7623497371790225888">"Ukufinyelela kwakamuva"</string> <string name="no_recent_access" msgid="4724297929902441784">"Awekho ama-app asanda kufinyelela ku-Health Connect"</string> @@ -790,8 +790,7 @@ <string name="data_sources_help_link" msgid="7740264923634947915">"Indlela imithombo nomsebenzi wokwenza kube okubalulekile"</string> <string name="data_sources_add_app" msgid="319926596123692514">"Faka i-app"</string> <string name="edit_data_sources" msgid="79641360876849547">"Hlela izinsiza ze-app"</string> - <!-- no translation found for default_app_summary (6183876151011837062) --> - <skip /> + <string name="default_app_summary" msgid="6183876151011837062">"Idivayisi ezenzakalelayo"</string> <string name="app_data_title" msgid="6499967982291000837">"Idatha ye-app"</string> <string name="no_data_footer" msgid="4777297654713673100">"Idatha ekuma-app akwazi ukufinyelela ku-Health Connect izovela lapha"</string> <string name="date_picker_day" msgid="3076687507968958991">"Usuku"</string> diff --git a/apk/res/values/strings.xml b/apk/res/values/strings.xml index 38c40352..cb1893d8 100644 --- a/apk/res/values/strings.xml +++ b/apk/res/values/strings.xml @@ -35,7 +35,7 @@ <!-- region Manage data --> <string name="backup_title" description="Label for a button that takes the user to backup settings. [CHAR_LIMIT=40]">Backup</string> - <string name="data_sources_and_priority_title" description="Label for a button that takes the user to app priority settings. [CHAR_LIMIT=40]">Data sources & priority</string> + <string name="data_sources_and_priority_title" description="Label for a button that takes the user to app priority settings. [CHAR_LIMIT=40]">Data sources and priority</string> <string name="set_units_title" description="Label for a button that takes the user to unit settings. [CHAR_LIMIT=40]">Set units</string> <!-- endregion --> diff --git a/apk/res/xml/data_sources_and_priority_screen.xml b/apk/res/xml/data_sources_and_priority_screen.xml index 4767019c..ee70416c 100644 --- a/apk/res/xml/data_sources_and_priority_screen.xml +++ b/apk/res/xml/data_sources_and_priority_screen.xml @@ -23,9 +23,11 @@ <PreferenceCategory android:key="app_sources_group" android:title="@string/app_sources_header" - android:order="2"/> + android:order="2" + app:isPreferenceVisible="false"/> <com.android.settingslib.widget.FooterPreference android:key="data_sources_footer" - android:title="@string/data_sources_footer"/> + android:title="@string/data_sources_footer" + app:isPreferenceVisible="false"/> </PreferenceScreen>
\ No newline at end of file diff --git a/apk/res/xml/empty_preference_screen.xml b/apk/res/xml/empty_preference_screen.xml new file mode 100644 index 00000000..624ed13a --- /dev/null +++ b/apk/res/xml/empty_preference_screen.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> + +</PreferenceScreen>
\ No newline at end of file diff --git a/apk/src/com/android/healthconnect/controller/MainActivity.kt b/apk/src/com/android/healthconnect/controller/MainActivity.kt index 4b13be60..93414f76 100644 --- a/apk/src/com/android/healthconnect/controller/MainActivity.kt +++ b/apk/src/com/android/healthconnect/controller/MainActivity.kt @@ -15,28 +15,37 @@ */ package com.android.healthconnect.controller +import android.app.Activity import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts.* import androidx.activity.viewModels import androidx.navigation.findNavController import com.android.healthconnect.controller.migration.MigrationActivity.Companion.maybeRedirectToMigrationActivity import com.android.healthconnect.controller.migration.MigrationViewModel import com.android.healthconnect.controller.navigation.DestinationChangedListener -import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.maybeRedirectToOnboardingActivity -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract.Companion.INTENT_RESULT_CANCELLED +import com.android.healthconnect.controller.onboarding.OnboardingActivity +import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.shouldRedirectToOnboardingActivity import com.android.healthconnect.controller.utils.activity.EmbeddingUtils.maybeRedirectIntoTwoPaneSettings import com.android.healthconnect.controller.utils.logging.HealthConnectLogger import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.runBlocking import javax.inject.Inject /** Entry point activity for Health Connect. */ @AndroidEntryPoint(CollapsingToolbarBaseActivity::class) class MainActivity : Hilt_MainActivity() { + @Inject lateinit var logger: HealthConnectLogger + private val migrationViewModel: MigrationViewModel by viewModels() + private val openOnboardingActivity = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_CANCELED) { + finish() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) @@ -47,8 +56,8 @@ class MainActivity : Hilt_MainActivity() { return } - if (maybeRedirectToOnboardingActivity(this) && savedInstanceState == null) { - openOnboardingActivity.launch(1) + if (savedInstanceState == null && shouldRedirectToOnboardingActivity(this)) { + openOnboardingActivity.launch(OnboardingActivity.createIntent(this)) } val currentMigrationState = migrationViewModel.getCurrentMigrationUiState() @@ -78,7 +87,6 @@ class MainActivity : Hilt_MainActivity() { if (!navController.popBackStack()) { finish() } - } override fun onNavigateUp(): Boolean { @@ -90,13 +98,6 @@ class MainActivity : Hilt_MainActivity() { return true } - val openOnboardingActivity = - registerForActivityResult(OnboardingActivityContract()) { result -> - if (result == INTENT_RESULT_CANCELLED) { - finish() - } - } - // TODO (b/270864219): implement interaction logging for the menu button // override fun onMenuOpened(featureId: Int, menu: Menu?): Boolean { // logger.logInteraction(ElementName.TOOLBAR_SETTINGS_BUTTON) diff --git a/apk/src/com/android/healthconnect/controller/data/DataManagementActivity.kt b/apk/src/com/android/healthconnect/controller/data/DataManagementActivity.kt index 60316557..36166c69 100644 --- a/apk/src/com/android/healthconnect/controller/data/DataManagementActivity.kt +++ b/apk/src/com/android/healthconnect/controller/data/DataManagementActivity.kt @@ -19,32 +19,39 @@ package com.android.healthconnect.controller.data import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment import com.android.healthconnect.controller.R -import com.android.healthconnect.controller.migration.MigrationActivity import com.android.healthconnect.controller.migration.MigrationActivity.Companion.maybeRedirectToMigrationActivity import com.android.healthconnect.controller.migration.MigrationActivity.Companion.maybeShowWhatsNewDialog import com.android.healthconnect.controller.migration.MigrationViewModel import com.android.healthconnect.controller.migration.api.MigrationState import com.android.healthconnect.controller.navigation.DestinationChangedListener -import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.maybeRedirectToOnboardingActivity -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract +import com.android.healthconnect.controller.onboarding.OnboardingActivity +import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.shouldRedirectToOnboardingActivity import com.android.healthconnect.controller.utils.FeatureUtils import com.android.healthconnect.controller.utils.activity.EmbeddingUtils.maybeRedirectIntoTwoPaneSettings import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.runBlocking /** Entry point activity for Health Connect Data Management controllers. */ @AndroidEntryPoint(CollapsingToolbarBaseActivity::class) class DataManagementActivity : Hilt_DataManagementActivity() { + @Inject lateinit var featureUtils: FeatureUtils private val migrationViewModel: MigrationViewModel by viewModels() + private val openOnboardingActivity = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_CANCELED) { + finish() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_data_management) @@ -56,8 +63,8 @@ class DataManagementActivity : Hilt_DataManagementActivity() { return } - if (maybeRedirectToOnboardingActivity(this) && savedInstanceState == null) { - openOnboardingActivity.launch(1) + if (savedInstanceState == null && shouldRedirectToOnboardingActivity(this)) { + openOnboardingActivity.launch(OnboardingActivity.createIntent(this)) } val currentMigrationState = migrationViewModel.getCurrentMigrationUiState() @@ -119,11 +126,4 @@ class DataManagementActivity : Hilt_DataManagementActivity() { } return true } - - val openOnboardingActivity = - registerForActivityResult(OnboardingActivityContract()) { result -> - if (result == OnboardingActivityContract.INTENT_RESULT_CANCELLED) { - finish() - } - } } diff --git a/apk/src/com/android/healthconnect/controller/data/access/AccessViewModel.kt b/apk/src/com/android/healthconnect/controller/data/access/AccessViewModel.kt index 0cd86d83..5701c68d 100644 --- a/apk/src/com/android/healthconnect/controller/data/access/AccessViewModel.kt +++ b/apk/src/com/android/healthconnect/controller/data/access/AccessViewModel.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch * [com.android.healthconnect.controller.dataaccess.HealthDataAccessFragment]. */ @HiltViewModel -class AccessViewModel @Inject constructor(private val loadAccessUseCase: LoadAccessUseCase) : +class AccessViewModel @Inject constructor(private val loadAccessUseCase: ILoadAccessUseCase) : ViewModel() { private val _appMetadataMap = MutableLiveData<AccessScreenState>() diff --git a/apk/src/com/android/healthconnect/controller/data/access/LoadAccessUseCase.kt b/apk/src/com/android/healthconnect/controller/data/access/LoadAccessUseCase.kt index f83ba9c5..4c679dbd 100644 --- a/apk/src/com/android/healthconnect/controller/data/access/LoadAccessUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/data/access/LoadAccessUseCase.kt @@ -15,7 +15,7 @@ */ package com.android.healthconnect.controller.data.access -import com.android.healthconnect.controller.permissions.api.GetGrantedHealthPermissionsUseCase +import com.android.healthconnect.controller.permissions.api.IGetGrantedHealthPermissionsUseCase import com.android.healthconnect.controller.permissions.data.HealthPermission import com.android.healthconnect.controller.permissions.data.HealthPermissionType import com.android.healthconnect.controller.permissions.data.PermissionsAccessType @@ -33,14 +33,14 @@ import kotlinx.coroutines.withContext class LoadAccessUseCase @Inject constructor( - private val loadPermissionTypeContributorAppsUseCase: LoadPermissionTypeContributorAppsUseCase, - private val loadGrantedHealthPermissionsUseCase: GetGrantedHealthPermissionsUseCase, + private val loadPermissionTypeContributorAppsUseCase: ILoadPermissionTypeContributorAppsUseCase, + private val loadGrantedHealthPermissionsUseCase: IGetGrantedHealthPermissionsUseCase, private val healthPermissionReader: HealthPermissionReader, private val appInfoReader: AppInfoReader, @IoDispatcher private val dispatcher: CoroutineDispatcher -) { +) : ILoadAccessUseCase { /** Returns a map of [AppAccessState] to apps. */ - suspend operator fun invoke( + override suspend operator fun invoke( permissionType: HealthPermissionType ): UseCaseResults<Map<AppAccessState, List<AppMetadata>>> = withContext(dispatcher) { @@ -97,3 +97,9 @@ constructor( return packageNames.sortedBy { appMetadata -> appMetadata.appName } } } + +interface ILoadAccessUseCase { + suspend fun invoke( + permissionType: HealthPermissionType + ): UseCaseResults<Map<AppAccessState, List<AppMetadata>>> +} diff --git a/apk/src/com/android/healthconnect/controller/data/access/LoadPermissionTypeContributorAppsUseCase.kt b/apk/src/com/android/healthconnect/controller/data/access/LoadPermissionTypeContributorAppsUseCase.kt index 36134810..6ae1266b 100644 --- a/apk/src/com/android/healthconnect/controller/data/access/LoadPermissionTypeContributorAppsUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/data/access/LoadPermissionTypeContributorAppsUseCase.kt @@ -37,10 +37,10 @@ constructor( private val appInfoReader: AppInfoReader, private val healthConnectManager: HealthConnectManager, @IoDispatcher private val dispatcher: CoroutineDispatcher -) { +) : ILoadPermissionTypeContributorAppsUseCase { /** Returns a list of [AppMetadata]s that have data in this [HealthPermissionType]. */ - suspend operator fun invoke(permissionType: HealthPermissionType): List<AppMetadata> = + override suspend operator fun invoke(permissionType: HealthPermissionType): List<AppMetadata> = withContext(dispatcher) { try { val recordTypeInfoMap: Map<Class<out Record>, RecordTypeInfoResponse> = @@ -64,3 +64,7 @@ constructor( } } } + +interface ILoadPermissionTypeContributorAppsUseCase { + suspend fun invoke(permissionType: HealthPermissionType): List<AppMetadata> +} diff --git a/apk/src/com/android/healthconnect/controller/data/entries/api/LoadDataEntriesUseCase.kt b/apk/src/com/android/healthconnect/controller/data/entries/api/LoadDataEntriesUseCase.kt index 475439da..a8b7ebae 100644 --- a/apk/src/com/android/healthconnect/controller/data/entries/api/LoadDataEntriesUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/data/entries/api/LoadDataEntriesUseCase.kt @@ -19,7 +19,6 @@ import com.android.healthconnect.controller.data.entries.FormattedEntry import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod import com.android.healthconnect.controller.permissions.data.HealthPermissionType import com.android.healthconnect.controller.service.IoDispatcher -import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper.getDataTypes import com.android.healthconnect.controller.shared.usecase.BaseUseCase import com.android.healthconnect.controller.shared.usecase.UseCaseResults import java.time.Instant @@ -37,17 +36,7 @@ constructor( ) : BaseUseCase<LoadDataEntriesInput, List<FormattedEntry>>(dispatcher), ILoadDataEntriesUseCase { override suspend fun execute(input: LoadDataEntriesInput): List<FormattedEntry> { - val timeFilterRange = - loadEntriesHelper.getTimeFilter( - input.displayedStartTime, input.period, endTimeExclusive = true) - val dataTypes = getDataTypes(input.permissionType) - - val entryRecords = - dataTypes - .map { dataType -> - loadEntriesHelper.readDataType(dataType, timeFilterRange, input.packageName) - } - .flatten() + val entryRecords = loadEntriesHelper.readRecords(input) return loadEntriesHelper.maybeAddDateSectionHeaders( entryRecords, input.period, input.showDataOrigin) diff --git a/apk/src/com/android/healthconnect/controller/data/entries/api/LoadEntriesHelper.kt b/apk/src/com/android/healthconnect/controller/data/entries/api/LoadEntriesHelper.kt index c761e3d4..241f350f 100644 --- a/apk/src/com/android/healthconnect/controller/data/entries/api/LoadEntriesHelper.kt +++ b/apk/src/com/android/healthconnect/controller/data/entries/api/LoadEntriesHelper.kt @@ -31,6 +31,7 @@ import com.android.healthconnect.controller.data.entries.FormattedEntry import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod import com.android.healthconnect.controller.data.entries.datenavigation.toPeriod import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter +import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper import com.android.healthconnect.controller.utils.LocalDateTimeFormatter import com.android.healthconnect.controller.utils.SystemTimeSource import com.android.healthconnect.controller.utils.TimeSource @@ -64,6 +65,9 @@ constructor( private const val TAG = "LoadDataUseCaseHelper" } + /** + * Returns a list of records from a data type sorted in descending order of their start time. + */ suspend fun readDataType( data: Class<out Record>, timeFilterRange: TimeInstantRangeFilter, @@ -80,6 +84,17 @@ constructor( return records } + /** Returns a list of records from an input sorted in descending order of their start time. */ + suspend fun readRecords(input: LoadDataEntriesInput): List<Record> { + val timeFilterRange = + getTimeFilter(input.displayedStartTime, input.period, endTimeExclusive = true) + val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(input.permissionType) + + return dataTypes + .map { dataType -> readDataType(dataType, timeFilterRange, input.packageName) } + .flatten() + } + /** * If more than one day's data is displayed, inserts a section header for each day: 'Today', * 'Yesterday', then date format. @@ -172,6 +187,7 @@ constructor( period: DateNavigationPeriod, endTimeExclusive: Boolean ): TimeInstantRangeFilter { + val start = startTime .atZone(ZoneId.systemDefault()) @@ -182,6 +198,7 @@ constructor( if (endTimeExclusive) { end = end.minus(Duration.ofMillis(1)) } + return TimeInstantRangeFilter.Builder().setStartTime(start).setEndTime(end).build() } diff --git a/apk/src/com/android/healthconnect/controller/data/entries/api/LoadSleepDataUseCase.kt b/apk/src/com/android/healthconnect/controller/data/entries/api/LoadSleepDataUseCase.kt deleted file mode 100644 index d8f865cf..00000000 --- a/apk/src/com/android/healthconnect/controller/data/entries/api/LoadSleepDataUseCase.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.android.healthconnect.controller.data.entries.api - -import android.health.connect.datatypes.Record -import com.android.healthconnect.controller.service.IoDispatcher -import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper -import com.android.healthconnect.controller.shared.usecase.BaseUseCase -import com.android.healthconnect.controller.shared.usecase.UseCaseResults -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineDispatcher - -@Singleton -class LoadSleepDataUseCase -@Inject -constructor( - @IoDispatcher private val dispatcher: CoroutineDispatcher, - private val loadEntriesHelper: LoadEntriesHelper -) : BaseUseCase<LoadDataEntriesInput, List<Record>>(dispatcher), ILoadSleepDataUseCase { - - /** - * Used to load the sleep session records. For aggregating sleep we need to know both the start - * and end times of each session. - */ - override suspend fun execute(input: LoadDataEntriesInput): List<Record> { - val timeFilterRange = - loadEntriesHelper.getTimeFilter( - input.displayedStartTime, input.period, endTimeExclusive = true) - val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(input.permissionType) - - return dataTypes - .map { dataType -> - loadEntriesHelper.readDataType(dataType, timeFilterRange, input.packageName) - } - .flatten() - } -} - -interface ILoadSleepDataUseCase { - suspend fun invoke(input: LoadDataEntriesInput): UseCaseResults<List<Record>> - - suspend fun execute(input: LoadDataEntriesInput): List<Record> -} diff --git a/apk/src/com/android/healthconnect/controller/datasources/AddAnAppFragment.kt b/apk/src/com/android/healthconnect/controller/datasources/AddAnAppFragment.kt index 9628dc97..ae379db4 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/AddAnAppFragment.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/AddAnAppFragment.kt @@ -18,7 +18,6 @@ package com.android.healthconnect.controller.datasources import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels -import androidx.lifecycle.MediatorLiveData import androidx.navigation.fragment.findNavController import com.android.healthconnect.controller.R import com.android.healthconnect.controller.categories.HealthDataCategoriesFragment.Companion.CATEGORY_KEY @@ -28,6 +27,8 @@ import com.android.healthconnect.controller.shared.HealthDataCategoryInt import com.android.healthconnect.controller.shared.app.AppMetadata import com.android.healthconnect.controller.shared.preference.HealthPreference import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment +import com.android.healthconnect.controller.utils.logging.AddAnAppElement +import com.android.healthconnect.controller.utils.logging.PageName import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint(HealthPreferenceFragment::class) @@ -38,6 +39,10 @@ class AddAnAppFragment : Hilt_AddAnAppFragment() { private var currentPriority: List<AppMetadata> = listOf() + init { + this.setPageName(PageName.ADD_AN_APP_PAGE) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { super.onCreatePreferences(savedInstanceState, rootKey) setPreferencesFromResource(R.xml.add_an_app_screen, rootKey) @@ -58,8 +63,13 @@ class AddAnAppFragment : Hilt_AddAnAppFragment() { setError(true) } else if (dataSourcesInfoState.isWithData()) { setLoading(false) - val currentPriorityList = (dataSourcesInfoState.priorityListState as PriorityListState.WithData).priorityList - val potentialAppSources = (dataSourcesInfoState.potentialAppSourcesState as PotentialAppSourcesState.WithData).appSources + val currentPriorityList = + (dataSourcesInfoState.priorityListState as PriorityListState.WithData) + .priorityList + val potentialAppSources = + (dataSourcesInfoState.potentialAppSourcesState + as PotentialAppSourcesState.WithData) + .appSources currentPriorityList.let { currentPriority = it } updateAppsList(potentialAppSources) } @@ -75,15 +85,18 @@ class AddAnAppFragment : Hilt_AddAnAppFragment() { HealthPreference(requireContext()).also { preference -> preference.title = appMetadata.appName preference.icon = appMetadata.icon + preference.logName = AddAnAppElement.POTENTIAL_PRIORITY_APP_BUTTON preference.setOnPreferenceClickListener { // add this app to the bottom of the priority list - val newPriority = currentPriority.toMutableList().also { - it.add(appMetadata) }.toList() + val newPriority = + currentPriority + .toMutableList() + .also { it.add(appMetadata) } + .toList() dataSourcesViewModel.updatePriorityList( newPriority.map { it.packageName }.toList(), category) - findNavController().navigate( - R.id.action_addAnAppFragment_to_dataSourcesFragment - ) + findNavController() + .navigate(R.id.action_addAnAppFragment_to_dataSourcesFragment) true } }) diff --git a/apk/src/com/android/healthconnect/controller/datasources/DataSourcesFragment.kt b/apk/src/com/android/healthconnect/controller/datasources/DataSourcesFragment.kt index 94eafb5d..45d6dac9 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/DataSourcesFragment.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/DataSourcesFragment.kt @@ -41,7 +41,9 @@ import com.android.healthconnect.controller.shared.preference.HealthPreferenceFr import com.android.healthconnect.controller.utils.AttributeResolver import com.android.healthconnect.controller.utils.DeviceInfoUtilsImpl import com.android.healthconnect.controller.utils.TimeSource +import com.android.healthconnect.controller.utils.logging.DataSourcesElement import com.android.healthconnect.controller.utils.logging.HealthConnectLogger +import com.android.healthconnect.controller.utils.logging.PageName import com.android.healthconnect.controller.utils.setupMenu import com.android.healthconnect.controller.utils.setupSharedMenu import com.android.settingslib.widget.FooterPreference @@ -69,8 +71,7 @@ class DataSourcesFragment : } init { - // TODO (b/292270118) update to correct name - // this.setPageName(PageName.MANAGE_DATA_PAGE) + this.setPageName(PageName.DATA_SOURCES_PAGE) } @Inject lateinit var logger: HealthConnectLogger @@ -116,16 +117,24 @@ class DataSourcesFragment : dataSourcesCategoriesStrings = dataSourcesCategories.map { category -> getString(category.uppercaseTitle()) } - setupSpinnerPreference() - } + if (requireArguments().containsKey(CATEGORY_KEY)) { + // Only require this from the HealthPermissionTypes screen + // When navigating here from the Manage Data screen we pass Unknown + // so that going back and forth to this screen does not restrict users to just one + // category + val argCategory = requireArguments().getInt(CATEGORY_KEY) + if (argCategory != HealthDataCategory.UNKNOWN) { + currentCategorySelection = argCategory + dataSourcesViewModel.setCurrentSelection(currentCategorySelection) + } + } - override fun onResume() { - super.onResume() - dataSourcesViewModel.loadData(currentCategorySelection) + setupSpinnerPreference() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setLoading(true) val currentStringSelection = spinnerPreference.selectedItem currentCategorySelection = dataSourcesCategories[dataSourcesCategoriesStrings.indexOf(currentStringSelection)] @@ -154,7 +163,7 @@ class DataSourcesFragment : if (priorityList.isEmpty() && potentialAppSources.isEmpty()) { addEmptyState() } else { - updateMenu(priorityList.size > 1) + updateMenu(priorityList.size > 1 && !dataSourcesViewModel.isEditMode) updateAppSourcesSection(priorityList, potentialAppSources) updateDataTotalsSection(cardInfos) } @@ -177,6 +186,11 @@ class DataSourcesFragment : } } + override fun onResume() { + super.onResume() + dataSourcesViewModel.loadData(currentCategorySelection) + } + private fun updateMenu(shouldShowEditButton: Boolean) { if (shouldShowEditButton) { setupMenu(R.menu.data_sources, viewLifecycleOwner, logger, onEditMenuItemSelected) @@ -186,6 +200,7 @@ class DataSourcesFragment : } private fun editPriorityList() { + dataSourcesViewModel.isEditMode = true updateMenu(shouldShowEditButton = false) appSourcesPreferenceGroup?.removePreferenceRecursively(ADD_AN_APP_PREFERENCE_KEY) val appSourcesPreference = @@ -199,6 +214,7 @@ class DataSourcesFragment : ?.toggleEditMode(false) updateMenu(dataSourcesViewModel.getEditedPriorityList().size > 1) updateAddApp(dataSourcesViewModel.getEditedPotentialAppSources().isNotEmpty()) + dataSourcesViewModel.isEditMode = false } /** Updates the priority list preference. */ @@ -218,9 +234,12 @@ class DataSourcesFragment : dataSourcesViewModel, currentCategorySelection, this) - .also { it.key = APP_SOURCES_PREFERENCE_KEY }) + .also { + it.key = APP_SOURCES_PREFERENCE_KEY + it.setEditMode(dataSourcesViewModel.isEditMode) + }) - updateAddApp(potentialAppSources.isNotEmpty()) + updateAddApp(potentialAppSources.isNotEmpty() && !dataSourcesViewModel.isEditMode) nonEmptyFooterPreference?.isVisible = true } @@ -241,6 +260,7 @@ class DataSourcesFragment : HealthPreference(requireContext()).also { it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.addIcon) it.title = getString(R.string.data_sources_add_app) + it.logName = DataSourcesElement.ADD_AN_APP_BUTTON it.key = ADD_AN_APP_PREFERENCE_KEY it.order = 100 // Arbitrary number to ensure the button is added at the end of the // priority list @@ -285,8 +305,8 @@ class DataSourcesFragment : dataTotalsPreferenceGroup?.isVisible = false } else { dataTotalsPreferenceGroup?.isVisible = true - cardContainerPreference?.setLoading(false) cardContainerPreference?.setAggregationCardInfo(cardInfos) + cardContainerPreference?.setLoading(false) } } } @@ -354,6 +374,8 @@ class DataSourcesFragment : position: Int, id: Long ) { + logger.logInteraction(DataSourcesElement.DATA_TYPE_SPINNER) + val currentCategory = dataSourcesCategories[position] currentCategorySelection = dataSourcesCategories[position] @@ -369,5 +391,6 @@ class DataSourcesFragment : dataSourcesCategories.indexOf(dataSourcesViewModel.getCurrentSelection())) preferenceScreen.addPreference(spinnerPreference) + logger.logImpression(DataSourcesElement.DATA_TYPE_SPINNER) } } diff --git a/apk/src/com/android/healthconnect/controller/datasources/DataSourcesViewModel.kt b/apk/src/com/android/healthconnect/controller/datasources/DataSourcesViewModel.kt index 32f19843..0d28ea53 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/DataSourcesViewModel.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/DataSourcesViewModel.kt @@ -75,6 +75,8 @@ constructor( val dataSourcesInfo: LiveData<DataSourcesInfo> get() = _dataSourcesInfo + var isEditMode = false + init { _dataSourcesAndAggregationsInfo.addSource(_currentPriorityList) { priorityListState -> if (!priorityListState.shouldObserve) { diff --git a/apk/src/com/android/healthconnect/controller/datasources/api/LoadLastDateWithPriorityDataUseCase.kt b/apk/src/com/android/healthconnect/controller/datasources/api/LoadLastDateWithPriorityDataUseCase.kt new file mode 100644 index 00000000..64232cb8 --- /dev/null +++ b/apk/src/com/android/healthconnect/controller/datasources/api/LoadLastDateWithPriorityDataUseCase.kt @@ -0,0 +1,145 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.android.healthconnect.controller.datasources.api + +import android.health.connect.HealthConnectManager +import androidx.core.os.asOutcomeReceiver +import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesInput +import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper +import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.permissiontypes.api.ILoadPriorityListUseCase +import com.android.healthconnect.controller.service.IoDispatcher +import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.fromHealthPermissionType +import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.utils.TimeSource +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay +import com.android.healthconnect.controller.utils.toLocalDate +import com.google.common.collect.Comparators.max +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +@Singleton +class LoadLastDateWithPriorityDataUseCase +@Inject +constructor( + private val healthConnectManager: HealthConnectManager, + private val loadEntriesHelper: LoadEntriesHelper, + private val loadPriorityListUseCase: ILoadPriorityListUseCase, + private val timeSource: TimeSource, + @IoDispatcher private val dispatcher: CoroutineDispatcher +) : ILoadLastDateWithPriorityDataUseCase { + + /** + * Returns the last local date with data for this health permission type, from the data owned by + * apps on the priority list. + */ + override suspend fun invoke( + healthPermissionType: HealthPermissionType + ): UseCaseResults<LocalDate?> = + withContext(dispatcher) { + var latestDateWithData: LocalDate? = null + try { + when (val priorityAppsResult = + loadPriorityListUseCase.invoke( + fromHealthPermissionType(healthPermissionType))) { + is UseCaseResults.Success -> { + val priorityApps = priorityAppsResult.data + + priorityApps.forEach { priorityApp -> + val lastDateWithDataForApp = + loadLastDateWithDataForApp( + healthPermissionType, priorityApp.packageName) + + latestDateWithData = + maxDateOrNull(latestDateWithData, lastDateWithDataForApp) + } + } + is UseCaseResults.Failed -> { + return@withContext UseCaseResults.Failed(priorityAppsResult.exception) + } + } + + return@withContext UseCaseResults.Success(latestDateWithData) + } catch (e: Exception) { + UseCaseResults.Failed(e) + } + } + + /** + * Returns the last date with data from a particular packageName, or null if no such date + * exists. + * + * To avoid querying all entries of all time, we first query for the activity dates for this + * healthPermissionType. We sort the dates in descending order and we find the first date which + * contains data from this packageName. + */ + private suspend fun loadLastDateWithDataForApp( + healthPermissionType: HealthPermissionType, + packageName: String + ): LocalDate? { + + val recordTypes = HealthPermissionToDatatypeMapper.getDataTypes(healthPermissionType) + + val datesWithData = suspendCancellableCoroutine { continuation -> + healthConnectManager.queryActivityDates( + recordTypes, Runnable::run, continuation.asOutcomeReceiver()) + } + + val today = timeSource.currentLocalDateTime().toLocalDate() + val recentDates = + datesWithData.filter { date -> + date.isAfter(today.minusMonths(1)) && !date.isAfter(today) + } + + if (recentDates.isEmpty()) return null + + val minDate = recentDates.min() + + // Query the data entries from this last month in one single API call + val input = + LoadDataEntriesInput( + permissionType = healthPermissionType, + packageName = packageName, + displayedStartTime = minDate.toInstantAtStartOfDay(), + period = DateNavigationPeriod.PERIOD_MONTH, + showDataOrigin = false) + + val entryRecords = loadEntriesHelper.readRecords(input) + + if (entryRecords.isNotEmpty()) { + // The records are returned in descending order by startTime + return loadEntriesHelper.getStartTime(entryRecords[0]).toLocalDate() + } + + return null + } + + private fun maxDateOrNull(firstDate: LocalDate?, secondDate: LocalDate?): LocalDate? { + if (firstDate == null && secondDate == null) return null + if (firstDate == null) return secondDate + if (secondDate == null) return firstDate + + return max(firstDate, secondDate) + } +} + +interface ILoadLastDateWithPriorityDataUseCase { + suspend fun invoke(healthPermissionType: HealthPermissionType): UseCaseResults<LocalDate?> +} diff --git a/apk/src/com/android/healthconnect/controller/datasources/api/LoadMostRecentAggregationsUseCase.kt b/apk/src/com/android/healthconnect/controller/datasources/api/LoadMostRecentAggregationsUseCase.kt index 12534aa4..4d202cfb 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/api/LoadMostRecentAggregationsUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/api/LoadMostRecentAggregationsUseCase.kt @@ -13,49 +13,39 @@ */ package com.android.healthconnect.controller.datasources.api -import android.health.connect.HealthConnectManager import android.health.connect.HealthDataCategory -import android.health.connect.datatypes.IntervalRecord -import android.health.connect.datatypes.Record -import androidx.core.os.asOutcomeReceiver import com.android.healthconnect.controller.data.entries.api.ILoadDataAggregationsUseCase -import com.android.healthconnect.controller.data.entries.api.ILoadSleepDataUseCase import com.android.healthconnect.controller.data.entries.api.LoadAggregationInput -import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesInput import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod import com.android.healthconnect.controller.datasources.AggregationCardInfo import com.android.healthconnect.controller.permissions.data.HealthPermissionType import com.android.healthconnect.controller.service.IoDispatcher import com.android.healthconnect.controller.shared.HealthDataCategoryInt -import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper import com.android.healthconnect.controller.shared.usecase.UseCaseResults -import com.android.healthconnect.controller.utils.atStartOfDay -import com.android.healthconnect.controller.utils.isAtLeastOneDayAfter -import com.android.healthconnect.controller.utils.isOnDayAfter -import com.android.healthconnect.controller.utils.isOnSameDay import com.android.healthconnect.controller.utils.toInstantAtStartOfDay -import com.android.healthconnect.controller.utils.toLocalDate -import com.google.common.collect.Comparators.max -import com.google.common.collect.Comparators.min import java.time.Instant import java.time.LocalDate -import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @Singleton class LoadMostRecentAggregationsUseCase @Inject constructor( - private val healthConnectManager: HealthConnectManager, private val loadDataAggregationsUseCase: ILoadDataAggregationsUseCase, - private val loadSleepDataUseCase: ILoadSleepDataUseCase, + private val loadLastDateWithPriorityDataUseCase: ILoadLastDateWithPriorityDataUseCase, + private val sleepSessionHelper: ISleepSessionHelper, @IoDispatcher private val dispatcher: CoroutineDispatcher, ) : ILoadMostRecentAggregationsUseCase { - /** Invoked to provide [AggregationDataCard]s info for Activity and Sleep */ + + /** + * Provides the most recent [AggregationDataCard]s info for Activity or Sleep. + * + * The latest aggregation always belongs to apps on the priority list. Apps not on the priority + * list do not contribute to aggregations or the last displayed date. + */ override suspend operator fun invoke( healthDataCategory: @HealthDataCategoryInt Int ): UseCaseResults<List<AggregationCardInfo>> = @@ -63,59 +53,45 @@ constructor( try { val resultsList = mutableListOf<AggregationCardInfo>() if (healthDataCategory == HealthDataCategory.ACTIVITY) { - val stepsRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.STEPS) - val datesWithStepsData = suspendCancellableCoroutine { continuation -> - healthConnectManager.queryActivityDates( - stepsRecordTypes, Runnable::run, continuation.asOutcomeReceiver()) - } - - if (datesWithStepsData.isNotEmpty()) { - val stepsCardInfo = - getLastAvailableAggregation( - datesWithStepsData, HealthPermissionType.STEPS) - stepsCardInfo?.let { resultsList.add(it) } - } - - val distanceRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.DISTANCE) - val datesWithDistanceData = suspendCancellableCoroutine { continuation -> - healthConnectManager.queryActivityDates( - distanceRecordTypes, Runnable::run, continuation.asOutcomeReceiver()) - } - - if (datesWithDistanceData.isNotEmpty()) { - val distanceCardInfo = - getLastAvailableAggregation( - datesWithDistanceData, HealthPermissionType.DISTANCE) - distanceCardInfo?.let { resultsList.add(it) } - } - - val caloriesRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes( + val activityPermissionTypesWithAggregations = + listOf( + HealthPermissionType.STEPS, + HealthPermissionType.DISTANCE, HealthPermissionType.TOTAL_CALORIES_BURNED) - val datesWithCaloriesData = suspendCancellableCoroutine { continuation -> - healthConnectManager.queryActivityDates( - caloriesRecordTypes, Runnable::run, continuation.asOutcomeReceiver()) - } - if (datesWithCaloriesData.isNotEmpty()) { - val caloriesCardInfo = - getLastAvailableAggregation( - datesWithCaloriesData, HealthPermissionType.TOTAL_CALORIES_BURNED) - caloriesCardInfo?.let { resultsList.add(it) } + activityPermissionTypesWithAggregations.forEach { permissionType -> + val lastDateWithData: LocalDate? + when (val lastDateWithDataResult = + loadLastDateWithPriorityDataUseCase.invoke(permissionType)) { + is UseCaseResults.Success -> { + lastDateWithData = lastDateWithDataResult.data + } + is UseCaseResults.Failed -> { + return@withContext UseCaseResults.Failed( + lastDateWithDataResult.exception) + } + } + + val cardInfo = + getLastAvailableActivityAggregation(lastDateWithData, permissionType) + cardInfo?.let { resultsList.add(it) } } } else if (healthDataCategory == HealthDataCategory.SLEEP) { - val sleepRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.SLEEP) - val datesWithSleepData = suspendCancellableCoroutine { continuation -> - healthConnectManager.queryActivityDates( - sleepRecordTypes, Runnable::run, continuation.asOutcomeReceiver()) - } - if (datesWithSleepData.isNotEmpty()) { - val sleepCardInfo = getLastAvailableSleepAggregation(datesWithSleepData) - sleepCardInfo?.let { resultsList.add(it) } + + val lastDateWithSleepData: LocalDate? + when (val lastDateWithSleepDataResult = + loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.SLEEP)) { + is UseCaseResults.Success -> { + lastDateWithSleepData = lastDateWithSleepDataResult.data + } + is UseCaseResults.Failed -> { + return@withContext UseCaseResults.Failed( + lastDateWithSleepDataResult.exception) + } } + + val sleepCardInfo = getLastAvailableSleepAggregation(lastDateWithSleepData) + sleepCardInfo?.let { resultsList.add(it) } } UseCaseResults.Success(resultsList.toList()) @@ -124,13 +100,16 @@ constructor( } } - private suspend fun getLastAvailableAggregation( - datesWithData: List<LocalDate>, + private suspend fun getLastAvailableActivityAggregation( + lastDateWithData: LocalDate?, healthPermissionType: HealthPermissionType ): AggregationCardInfo? { + if (lastDateWithData == null) { + return null + } + // Get aggregate for last day - val lastDate = datesWithData.maxOf { it } - val lastDateInstant = lastDate.atStartOfDay(ZoneId.systemDefault()).toInstant() + val lastDateInstant = lastDateWithData.toInstantAtStartOfDay() // call for aggregate val input = @@ -147,190 +126,30 @@ constructor( AggregationCardInfo(healthPermissionType, useCaseResult.data, lastDateInstant) } is UseCaseResults.Failed -> { - // Something went wrong here, so return nothing - null + throw useCaseResult.exception } } } private suspend fun getLastAvailableSleepAggregation( - datesWithData: List<LocalDate> + lastDateWithData: LocalDate? ): AggregationCardInfo? { - // Get last date with data (the start date of sleep sessions) - val lastDateWithData = datesWithData.last() - val lastDateInstant = lastDateWithData.toInstantAtStartOfDay() - - // Get all sleep sessions starting on that date - val input = - LoadDataEntriesInput( - HealthPermissionType.SLEEP, - packageName = null, - displayedStartTime = lastDateInstant, - period = DateNavigationPeriod.PERIOD_DAY, - showDataOrigin = false) - - return when (val result = loadSleepDataUseCase.invoke(input)) { - is UseCaseResults.Success -> { - val sleepRecords = result.data - val (minStartTime, maxEndTime) = - clusterSleepSessions(sleepRecords, lastDateWithData) - computeSleepAggregation(minStartTime, maxEndTime) - } - is UseCaseResults.Failed -> { - null - } - } - } - - /** - * Given a list of sleep session records starting on the last date with data, returns a pair of - * Instants representing a time interval [minStartTime, maxEndTime] between which we will query - * the aggregated time of sleep sessions. - */ - private suspend fun clusterSleepSessions( - entries: List<Record>, - lastDateWithData: LocalDate - ): Pair<Instant, Instant> { - - var minStartTime: Instant = Instant.MAX - var maxEndTime: Instant = Instant.MIN - - // Determine if there is at least one session starting on Day 2 and finishing on Day 3 - // (Case 3) - val sessionsCrossingMidnight = - entries.any { record -> - val currentSleepSession = (record as IntervalRecord) - (currentSleepSession.endTime.isAtLeastOneDayAfter(currentSleepSession.startTime)) - } - - // Handle Case 3 - at least one sleep session starts on Day 2 and finishes on Day 3 - if (sessionsCrossingMidnight) { - return handleSessionsCrossingMidnight(entries) + if (lastDateWithData == null) { + return null } - // case 1 - start and end times on the same day (Day 2) - // case 2 - there might be sessions starting on Day 1 and finishing on Day 2 - // All sessions start and end on this day - // now we look at the date before to see if there is a session - // that ends today - val secondToLastDateInstant = - lastDateWithData.minusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant() - val lastDateWithDataInstant = lastDateWithData.toInstantAtStartOfDay() - - // Get all sleep sessions starting on secondToLastDate - val input = - LoadDataEntriesInput( - HealthPermissionType.SLEEP, - packageName = null, - displayedStartTime = secondToLastDateInstant, - period = DateNavigationPeriod.PERIOD_DAY, - showDataOrigin = false) - - when (val result = loadSleepDataUseCase.invoke(input)) { + when (val result = sleepSessionHelper.clusterSleepSessions(lastDateWithData)) { is UseCaseResults.Success -> { - val previousDaySleepData = result.data - // For each session check if the end date is last date - // If we find it, extend minStartTime to the start time of that session - - if (previousDaySleepData.isEmpty()) { - // Case 1 - All sessions start and end on this day (Day 2) - minStartTime = entries.minOf { (it as IntervalRecord).startTime } - maxEndTime = entries.maxOf { (it as IntervalRecord).endTime } - } else { - // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later - return handleSessionsStartingOnSecondToLastDate( - previousDaySleepData, lastDateWithDataInstant) + result.data?.let { pair -> + return computeSleepAggregation(pair.first, pair.second) } } is UseCaseResults.Failed -> { - Pair(Instant.MAX, Instant.MAX) - } - } - - return Pair(minStartTime, maxEndTime) - } - - /** Handles sleep session case 3 - At least one session crosses midnight into Day 3. */ - private fun handleSessionsCrossingMidnight(entries: List<Record>): Pair<Instant, Instant> { - // We show aggregation for all sessions ending on day 3 - // Find the max end time from all sessions crossing midnight - // and the min start time from all sessions that end on day 3 - // There can be no session starting on day 3, otherwise that would be the latest date - var minStartTime: Instant = Instant.MAX - var maxEndTime: Instant = Instant.MIN - - entries.forEach { record -> - val currentSleepSession = (record as IntervalRecord) - // Start day = Day 2 - // We look at most 2 calendar days in the future, so the max possible end time - // is Day 4 at 12:00am - val maxPossibleEnd = - currentSleepSession.startTime - .toLocalDate() - .atStartOfDay(ZoneId.systemDefault()) - .plusDays(2) - .toInstant() - - if (currentSleepSession.endTime.isOnSameDay(currentSleepSession.startTime)) { - // This sleep session starts and ends on Day 2 - // So we do not count this for either min or max - // As it belongs to the aggregations for Day 2 - } else if (currentSleepSession.endTime.isOnDayAfter(currentSleepSession.startTime)) { - // This is a session [Day 2 - Day 3] - // min and max candidate - minStartTime = min(minStartTime, currentSleepSession.startTime) - maxEndTime = max(maxEndTime, currentSleepSession.endTime) - } else { - // currentSleepSession.endTime is further than Day 3 - // Max End time should be Day 4 at 12am - minStartTime = min(minStartTime, currentSleepSession.startTime) - maxEndTime = max(maxEndTime, maxPossibleEnd) - } - } - - return Pair(minStartTime, maxEndTime) - } - - /** - * Handles sleep session Case 2 - At least one session starts on Day 1 and finishes on Day 2 or - * later. - */ - private fun handleSessionsStartingOnSecondToLastDate( - previousDaySleepData: List<Record>, - lastDateWithDataInstant: Instant - ): Pair<Instant, Instant> { - var minStartTime: Instant = Instant.MAX - var maxEndTime: Instant = Instant.MIN - - previousDaySleepData.forEach { record -> - val currentSleepSession = (record as IntervalRecord) - - // Start date is Day 1, so the max possible end date is Day 3 12am - val maxPossibleEnd = - currentSleepSession.startTime - .toLocalDate() - .atStartOfDay(ZoneId.systemDefault()) - .plusDays(2) - .toInstant() - - if (currentSleepSession.endTime.isOnSameDay(lastDateWithDataInstant)) { - // This is a sleep session that starts on Day 1 and finishes on Day 2 - // min/max candidate - minStartTime = min(minStartTime, currentSleepSession.startTime) - maxEndTime = max(maxEndTime, currentSleepSession.endTime) - } else if (currentSleepSession.endTime.isOnSameDay(currentSleepSession.startTime)) { - // This is a sleep session that starts and ends on Day 1 - // We do not count it for min/max because this belongs to Day 1 - // aggregation - } else { - // This is a sleep session that start on Day 1 and ends after Day 2 - // Then the max end time should be Day 3 at 12am - minStartTime = min(minStartTime, currentSleepSession.startTime) - maxEndTime = max(maxEndTime, maxPossibleEnd) + throw result.exception } } - return Pair(minStartTime, maxEndTime) + return null } /** @@ -340,7 +159,7 @@ constructor( private suspend fun computeSleepAggregation( minStartTime: Instant, maxEndTime: Instant - ): AggregationCardInfo? { + ): AggregationCardInfo { val aggregationInput = LoadAggregationInput.CustomAggregation( permissionType = HealthPermissionType.SLEEP, @@ -353,14 +172,10 @@ constructor( is UseCaseResults.Success -> { // use this aggregation value to construct the card AggregationCardInfo( - HealthPermissionType.SLEEP, - useCaseResult.data, - minStartTime.atStartOfDay(), - maxEndTime.atStartOfDay()) + HealthPermissionType.SLEEP, useCaseResult.data, minStartTime, maxEndTime) } is UseCaseResults.Failed -> { - // Something went wrong here, so return nothing - null + throw useCaseResult.exception } } } diff --git a/apk/src/com/android/healthconnect/controller/datasources/api/LoadPriorityEntriesUseCase.kt b/apk/src/com/android/healthconnect/controller/datasources/api/LoadPriorityEntriesUseCase.kt new file mode 100644 index 00000000..3ffaf13b --- /dev/null +++ b/apk/src/com/android/healthconnect/controller/datasources/api/LoadPriorityEntriesUseCase.kt @@ -0,0 +1,80 @@ +package com.android.healthconnect.controller.datasources.api + +import android.health.connect.datatypes.Record +import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesInput +import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper +import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.permissiontypes.api.ILoadPriorityListUseCase +import com.android.healthconnect.controller.service.IoDispatcher +import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +@Singleton +class LoadPriorityEntriesUseCase +@Inject +constructor( + private val loadEntriesHelper: LoadEntriesHelper, + private val loadPriorityListUseCase: ILoadPriorityListUseCase, + @IoDispatcher private val dispatcher: CoroutineDispatcher +) : ILoadPriorityEntriesUseCase { + + /** + * Returns a list of records from the specified date originating from any of the apps on the + * priority list for this healthPermissionType. + */ + override suspend fun invoke( + healthPermissionType: HealthPermissionType, + localDate: LocalDate + ): UseCaseResults<List<Record>> = + withContext(dispatcher) { + try { + val localDateInstant = localDate.toInstantAtStartOfDay() + val records = mutableListOf<Record>() + + when (val priorityAppsResult = + loadPriorityListUseCase.invoke( + HealthDataCategoryExtensions.fromHealthPermissionType( + healthPermissionType))) { + is UseCaseResults.Success -> { + val priorityApps = priorityAppsResult.data + + priorityApps.forEach { priorityApp -> + val input = + LoadDataEntriesInput( + HealthPermissionType.SLEEP, + packageName = priorityApp.packageName, + displayedStartTime = localDateInstant, + period = DateNavigationPeriod.PERIOD_DAY, + showDataOrigin = false) + val entryRecords = loadEntriesHelper.readRecords(input) + + records.addAll(entryRecords) + } + } + is UseCaseResults.Failed -> { + throw priorityAppsResult.exception + } + } + + // Sorted for testing + UseCaseResults.Success( + records.sortedByDescending { loadEntriesHelper.getStartTime(it) }) + } catch (e: Exception) { + UseCaseResults.Failed(e) + } + } +} + +interface ILoadPriorityEntriesUseCase { + suspend fun invoke( + healthPermissionType: HealthPermissionType, + localDate: LocalDate + ): UseCaseResults<List<Record>> +} diff --git a/apk/src/com/android/healthconnect/controller/datasources/api/SleepSessionHelper.kt b/apk/src/com/android/healthconnect/controller/datasources/api/SleepSessionHelper.kt new file mode 100644 index 00000000..728bc765 --- /dev/null +++ b/apk/src/com/android/healthconnect/controller/datasources/api/SleepSessionHelper.kt @@ -0,0 +1,207 @@ +package com.android.healthconnect.controller.datasources.api + +import android.health.connect.datatypes.IntervalRecord +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.SleepSessionRecord +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.service.IoDispatcher +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.utils.isAtLeastOneDayAfter +import com.android.healthconnect.controller.utils.isOnDayAfter +import com.android.healthconnect.controller.utils.isOnSameDay +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay +import com.android.healthconnect.controller.utils.toLocalDate +import com.google.common.collect.Comparators +import java.lang.Exception +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +@Singleton +class SleepSessionHelper +@Inject +constructor( + private val loadPriorityEntriesUseCase: ILoadPriorityEntriesUseCase, + @IoDispatcher private val dispatcher: CoroutineDispatcher, +) : ISleepSessionHelper { + + /** + * Given a list of sleep session records starting on the last date with data, returns a pair of + * Instants representing a time interval [minStartTime, maxEndTime] between which we will query + * the aggregated time of sleep sessions. + */ + override suspend fun clusterSleepSessions( + lastDateWithData: LocalDate + ): UseCaseResults<Pair<Instant, Instant>?> = + withContext(dispatcher) { + try { + val currentDaySleepData = getPrioritySleepRecords(lastDateWithData) + + if (currentDaySleepData.isEmpty()) { + return@withContext UseCaseResults.Success(null) + } + + // Determine if there is at least one session starting on Day 2 and finishing on Day + // 3 + // (Case 3) + val sessionsCrossingMidnight = + currentDaySleepData.any { record -> + val currentSleepSession = (record as IntervalRecord) + (currentSleepSession.endTime.isAtLeastOneDayAfter( + currentSleepSession.startTime)) + } + + // Handle Case 3 - at least one sleep session starts on Day 2 and finishes on Day 3 + if (sessionsCrossingMidnight) { + return@withContext UseCaseResults.Success( + handleSessionsCrossingMidnight(currentDaySleepData)) + } + + // case 1 - start and end times on the same day (Day 2) + // case 2 - there might be sessions starting on Day 1 and finishing on Day 2 + // All sessions start and end on this day + // now we look at the date before to see if there is a session + // that ends today + val secondToLastDayWithData = lastDateWithData.minusDays(1) + val lastDateWithDataInstant = lastDateWithData.toInstantAtStartOfDay() + + // Get all sleep sessions starting on secondToLastDate + val previousDaySleepData = getPrioritySleepRecords(secondToLastDayWithData) + + // For each session check if the end date is last date + // If we find it, extend minStartTime to the start time of that session + // Case 1 - All sessions start and end on this day (Day 2) + // We also need these for case2 + val minStartTime: Instant = + currentDaySleepData.minOf { (it as IntervalRecord).startTime } + val maxEndTime: Instant = + currentDaySleepData.maxOf { (it as IntervalRecord).endTime } + + if (previousDaySleepData.isNotEmpty()) { + // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later + return@withContext UseCaseResults.Success( + handleSessionsStartingOnSecondToLastDate( + previousDaySleepData, + lastDateWithDataInstant, + minStartTime, + maxEndTime)) + } + + return@withContext UseCaseResults.Success(Pair(minStartTime, maxEndTime)) + } catch (e: Exception) { + return@withContext UseCaseResults.Failed(e) + } + } + + /** Handles sleep session case 3 - At least one session crosses midnight into Day 3. */ + private fun handleSessionsCrossingMidnight(entries: List<Record>): Pair<Instant, Instant> { + // We show aggregation for all sessions ending on day 3 + // Find the max end time from all sessions crossing midnight + // and the min start time from all sessions that end on day 3 + // There can be no session starting on day 3, otherwise that would be the latest date + var minStartTime: Instant = Instant.MAX + var maxEndTime: Instant = Instant.MIN + + entries.forEach { record -> + val currentSleepSession = (record as IntervalRecord) + // Start day = Day 2 + // We look at most 2 calendar days in the future, so the max possible end time + // is Day 4 at 12:00am + val maxPossibleEnd = + currentSleepSession.startTime + .toLocalDate() + .atStartOfDay(ZoneId.systemDefault()) + .plusDays(2) + .toInstant() + + if (currentSleepSession.endTime.isOnSameDay(currentSleepSession.startTime)) { + // This sleep session starts and ends on Day 2 + // So we do not count this for either min or max + // As it belongs to the aggregations for Day 2 + } else if (currentSleepSession.endTime.isOnDayAfter(currentSleepSession.startTime)) { + // This is a session [Day 2 - Day 3] + // min and max candidate + minStartTime = Comparators.min(minStartTime, currentSleepSession.startTime) + maxEndTime = Comparators.max(maxEndTime, currentSleepSession.endTime) + } else { + // currentSleepSession.endTime is further than Day 3 + // Max End time should be Day 4 at 12am + minStartTime = Comparators.min(minStartTime, currentSleepSession.startTime) + maxEndTime = Comparators.max(maxEndTime, maxPossibleEnd) + } + } + + return Pair(minStartTime, maxEndTime) + } + + /** + * Handles sleep session Case 2 - At least one session starts on Day 1 and finishes on Day 2 or + * later. + */ + private fun handleSessionsStartingOnSecondToLastDate( + previousDaySleepData: List<Record>, + lastDateWithDataInstant: Instant, + lastDayMinStartTime: Instant, + lastDayMaxEndTime: Instant + ): Pair<Instant, Instant> { + + // This ensures we also take into account the sessions from lastDateWithData + var minStartTime = lastDayMinStartTime + var maxEndTime = lastDayMaxEndTime + + previousDaySleepData.forEach { record -> + val currentSleepSession = (record as IntervalRecord) + + // Start date is Day 1, so the max possible end date is Day 3 12am + val maxPossibleEnd = + currentSleepSession.startTime + .toLocalDate() + .atStartOfDay(ZoneId.systemDefault()) + .plusDays(2) + .toInstant() + + if (currentSleepSession.endTime.isOnSameDay(lastDateWithDataInstant)) { + // This is a sleep session that starts on Day 1 and finishes on Day 2 + // min/max candidate + minStartTime = Comparators.min(minStartTime, currentSleepSession.startTime) + maxEndTime = Comparators.max(maxEndTime, currentSleepSession.endTime) + } else if (currentSleepSession.endTime.isOnSameDay(currentSleepSession.startTime)) { + // This is a sleep session that starts and ends on Day 1 + // We do not count it for min/max because this belongs to Day 1 + // aggregation + } else { + // This is a sleep session that start on Day 1 and ends after Day 2 + // Then the max end time should be Day 3 at 12am + minStartTime = Comparators.min(minStartTime, currentSleepSession.startTime) + maxEndTime = Comparators.max(maxEndTime, maxPossibleEnd) + } + } + + return Pair(minStartTime, maxEndTime) + } + + /** Returns all priority sleep records starting on lastDateWithData. */ + private suspend fun getPrioritySleepRecords( + lastDateWithData: LocalDate + ): List<SleepSessionRecord> { + when (val result = + loadPriorityEntriesUseCase.invoke(HealthPermissionType.SLEEP, lastDateWithData)) { + is UseCaseResults.Success -> { + return result.data.map { it as SleepSessionRecord } + } + is UseCaseResults.Failed -> { + throw result.exception + } + } + } +} + +interface ISleepSessionHelper { + suspend fun clusterSleepSessions( + lastDateWithData: LocalDate + ): UseCaseResults<Pair<Instant, Instant>?> +} diff --git a/apk/src/com/android/healthconnect/controller/datasources/api/UpdatePriorityListUseCase.kt b/apk/src/com/android/healthconnect/controller/datasources/api/UpdatePriorityListUseCase.kt index 5f5e0e3a..21d48705 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/api/UpdatePriorityListUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/api/UpdatePriorityListUseCase.kt @@ -1,3 +1,16 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ package com.android.healthconnect.controller.datasources.api import android.health.connect.HealthConnectManager @@ -17,10 +30,13 @@ class UpdatePriorityListUseCase constructor( private val healthConnectManager: HealthConnectManager, @IoDispatcher private val dispatcher: CoroutineDispatcher -): IUpdatePriorityListUseCase { +) : IUpdatePriorityListUseCase { /** Updates the priority list of the stored [DataOrigin]s for given [HealthDataCategory]. */ - override suspend operator fun invoke(priorityList: List<String>, category: @HealthDataCategoryInt Int) { + override suspend operator fun invoke( + priorityList: List<String>, + category: @HealthDataCategoryInt Int + ) { withContext(dispatcher) { val dataOrigins: List<DataOrigin> = priorityList diff --git a/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesAdapter.kt b/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesAdapter.kt index c0497c29..4b9b6bb5 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesAdapter.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesAdapter.kt @@ -29,6 +29,11 @@ import com.android.healthconnect.controller.shared.HealthDataCategoryInt import com.android.healthconnect.controller.shared.app.AppMetadata import com.android.healthconnect.controller.shared.app.AppUtils import com.android.healthconnect.controller.utils.AttributeResolver +import com.android.healthconnect.controller.utils.logging.DataSourcesElement +import com.android.healthconnect.controller.utils.logging.ElementName +import com.android.healthconnect.controller.utils.logging.HealthConnectLogger +import com.android.healthconnect.controller.utils.logging.HealthConnectLoggerEntryPoint +import dagger.hilt.android.EntryPointAccessors import java.text.NumberFormat /** RecyclerView adapter that holds the list of app sources for this [HealthDataCategory]. */ @@ -50,6 +55,16 @@ class AppSourcesAdapter( private val POSITION_CHANGED_PAYLOAD = Any() + private var logger: HealthConnectLogger + var logName: ElementName = DataSourcesElement.DATA_TOTALS_CARD + + init { + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + context.applicationContext, HealthConnectLoggerEntryPoint::class.java) + logger = hiltEntryPoint.logger() + } + interface OnAppRemovedFromPriorityListListener { fun onAppRemovedFromPriorityList() } @@ -153,6 +168,8 @@ class AppSourcesAdapter( } else { setupItemForDragMode(isOnlyApp) } + + logger.logImpression(DataSourcesElement.APP_SOURCE_BUTTON) } private fun setupItemForEditMode(appPosition: Int) { @@ -162,6 +179,8 @@ class AppSourcesAdapter( AttributeResolver.getDrawable(itemView.context, R.attr.closeIcon) actionView.setOnTouchListener(null) actionView.setOnClickListener { + logger.logInteraction(DataSourcesElement.REMOVE_APP_SOURCE_BUTTON) + val currentPriority = priorityList.toMutableList() val removedItem = currentPriority.removeAt(appPosition) dataSourcesViewModel.setEditedPriorityList(currentPriority) @@ -175,6 +194,7 @@ class AppSourcesAdapter( removeItem(appPosition) onAppRemovedListener.onAppRemovedFromPriorityList() } + logger.logImpression(DataSourcesElement.REMOVE_APP_SOURCE_BUTTON) } // These items are not clickable and so onTouch does not need to reimplement click @@ -191,12 +211,14 @@ class AppSourcesAdapter( AttributeResolver.getDrawable(itemView.context, R.attr.priorityItemDragIcon) actionView.setOnClickListener(null) actionView.setOnTouchListener { _, event -> + logger.logInteraction(DataSourcesElement.REORDER_APP_SOURCE_BUTTON) if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_UP) { onItemDragStartedListener?.startDrag(this) } false } + logger.logImpression(DataSourcesElement.REORDER_APP_SOURCE_BUTTON) } } } diff --git a/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesPreference.kt b/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesPreference.kt index 4c7843bb..453dd3ad 100644 --- a/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesPreference.kt +++ b/apk/src/com/android/healthconnect/controller/datasources/appsources/AppSourcesPreference.kt @@ -39,6 +39,7 @@ constructor( private var potentialAppSourcesList: List<AppMetadata> = listOf() private lateinit var priorityListView: RecyclerView private lateinit var adapter: AppSourcesAdapter + private var isEditMode = false init { layoutResource = R.layout.widget_linear_layout_preference @@ -64,6 +65,8 @@ constructor( priorityListView.adapter = adapter priorityListView.layoutManager = AppSourcesLinearLayoutManager(context, adapter) createAndAttachItemMoveCallback() + + adapter.toggleEditMode(isEditMode) } override fun attachCallback() { @@ -77,10 +80,20 @@ constructor( priorityListMover.attachToRecyclerView(priorityListView) } + /** Toggles the edit mode on/off after the preference has been created */ fun toggleEditMode(isEditMode: Boolean) { + setEditMode(isEditMode) adapter.toggleEditMode(isEditMode) } + /** + * Sets the edit mode on/off before the preference is fully created and the onBindViewHolder + * method is called. + */ + fun setEditMode(isEditMode: Boolean) { + this.isEditMode = isEditMode + } + override fun isSameItem(preference: Preference): Boolean { return preference is AppSourcesPreference && this == preference } diff --git a/apk/src/com/android/healthconnect/controller/managedata/ManageDataFragment.kt b/apk/src/com/android/healthconnect/controller/managedata/ManageDataFragment.kt index d47abd84..ab58823c 100644 --- a/apk/src/com/android/healthconnect/controller/managedata/ManageDataFragment.kt +++ b/apk/src/com/android/healthconnect/controller/managedata/ManageDataFragment.kt @@ -1,13 +1,16 @@ package com.android.healthconnect.controller.managedata +import android.health.connect.HealthDataCategory import android.icu.text.MessageFormat import android.os.Bundle import android.view.View +import androidx.core.os.bundleOf import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.android.healthconnect.controller.R import com.android.healthconnect.controller.autodelete.AutoDeleteRange import com.android.healthconnect.controller.autodelete.AutoDeleteViewModel +import com.android.healthconnect.controller.categories.HealthDataCategoriesFragment import com.android.healthconnect.controller.shared.preference.HealthPreference import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment import com.android.healthconnect.controller.utils.FeatureUtils @@ -30,8 +33,7 @@ class ManageDataFragment : Hilt_ManageDataFragment() { } private val autoDeleteViewModel: AutoDeleteViewModel by activityViewModels() - @Inject - lateinit var featureUtils: FeatureUtils + @Inject lateinit var featureUtils: FeatureUtils private val mAutoDeletePreference: HealthPreference? by lazy { preferenceScreen.findPreference(AUTO_DELETE_PREFERENCE_KEY) @@ -58,7 +60,12 @@ class ManageDataFragment : Hilt_ManageDataFragment() { if (featureUtils.isNewAppPriorityEnabled()) { mDataSourcesPreference?.logName = ManageDataElement.DATA_SOURCES_AND_PRIORITY_BUTTON mDataSourcesPreference?.setOnPreferenceClickListener { - findNavController().navigate(R.id.action_manageData_to_dataSources) + findNavController() + .navigate( + R.id.action_manageData_to_dataSources, + bundleOf( + HealthDataCategoriesFragment.CATEGORY_KEY to + HealthDataCategory.UNKNOWN)) true } } else { @@ -96,13 +103,13 @@ class ManageDataFragment : Hilt_ManageDataFragment() { AutoDeleteRange.AUTO_DELETE_RANGE_THREE_MONTHS -> { val count = AutoDeleteRange.AUTO_DELETE_RANGE_THREE_MONTHS.numberOfMonths MessageFormat.format( - getString(R.string.range_after_x_months), mapOf("count" to count)) + getString(R.string.range_after_x_months), mapOf("count" to count)) } AutoDeleteRange.AUTO_DELETE_RANGE_EIGHTEEN_MONTHS -> { val count = AutoDeleteRange.AUTO_DELETE_RANGE_EIGHTEEN_MONTHS.numberOfMonths MessageFormat.format( - getString(R.string.range_after_x_months), mapOf("count" to count)) + getString(R.string.range_after_x_months), mapOf("count" to count)) } } } -}
\ No newline at end of file +} diff --git a/apk/src/com/android/healthconnect/controller/migration/AppUpdateRequiredFragment.kt b/apk/src/com/android/healthconnect/controller/migration/AppUpdateRequiredFragment.kt index f92e872d..f5dcfc14 100644 --- a/apk/src/com/android/healthconnect/controller/migration/AppUpdateRequiredFragment.kt +++ b/apk/src/com/android/healthconnect/controller/migration/AppUpdateRequiredFragment.kt @@ -27,7 +27,8 @@ import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.android.healthconnect.controller.R -import com.android.healthconnect.controller.utils.getAppStoreLink +import com.android.healthconnect.controller.utils.AppStoreUtils +import com.android.healthconnect.controller.utils.NavigationUtils import com.android.healthconnect.controller.utils.logging.HealthConnectLogger import com.android.healthconnect.controller.utils.logging.MigrationElement import com.android.healthconnect.controller.utils.logging.PageName @@ -38,6 +39,8 @@ import javax.inject.Inject class AppUpdateRequiredFragment : Hilt_AppUpdateRequiredFragment() { @Inject lateinit var logger: HealthConnectLogger + @Inject lateinit var appStoreUtils: AppStoreUtils + @Inject lateinit var navigationUtils: NavigationUtils companion object { private const val TAG = "AppUpdateFragment" @@ -83,8 +86,8 @@ class AppUpdateRequiredFragment : Hilt_AppUpdateRequiredFragment() { try { val packageName = getString(resources.getIdentifier(HC_PACKAGE_NAME_CONFIG_NAME, null, null)) - val intent = getAppStoreLink(requireContext(), packageName) - startActivity(intent!!) + val intent = appStoreUtils.getAppStoreLink(packageName) + navigationUtils.startActivity(this, intent!!) } catch (exception: Exception) { Log.e(TAG, "App store activity does not exist", exception) Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT).show() @@ -103,8 +106,8 @@ class AppUpdateRequiredFragment : Hilt_AppUpdateRequiredFragment() { putBoolean(getString(R.string.app_update_needed_seen), true) apply() } - findNavController() - .navigate(R.id.action_migrationAppUpdateNeededFragment_to_homeScreen) + navigationUtils.navigate( + this, R.id.action_migrationAppUpdateNeededFragment_to_homeScreen) } requireActivity().finish() } diff --git a/apk/src/com/android/healthconnect/controller/migration/MigrationNavigationFragment.kt b/apk/src/com/android/healthconnect/controller/migration/MigrationNavigationFragment.kt index 07433568..ca7213ae 100644 --- a/apk/src/com/android/healthconnect/controller/migration/MigrationNavigationFragment.kt +++ b/apk/src/com/android/healthconnect/controller/migration/MigrationNavigationFragment.kt @@ -3,28 +3,26 @@ package com.android.healthconnect.controller.migration import android.content.Context import android.content.SharedPreferences import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController import com.android.healthconnect.controller.R import com.android.healthconnect.controller.migration.api.MigrationState import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment +import com.android.healthconnect.controller.utils.NavigationUtils import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint(HealthPreferenceFragment::class) class MigrationNavigationFragment : Hilt_MigrationNavigationFragment() { + @Inject lateinit var navigationUtils: NavigationUtils + private val migrationViewModel: MigrationViewModel by viewModels() private lateinit var sharedPreference: SharedPreferences - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_migration_navigation, container, false) + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + super.onCreatePreferences(savedInstanceState, rootKey) + setPreferencesFromResource(R.xml.empty_preference_screen, rootKey) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -75,28 +73,27 @@ class MigrationNavigationFragment : Hilt_MigrationNavigationFragment() { } private fun showInProgressFragment() { - findNavController() - .navigate(R.id.action_migrationNavigationFragment_to_migrationInProgressFragment) + navigationUtils.navigate( + this, R.id.action_migrationNavigationFragment_to_migrationInProgressFragment) } private fun showAppUpdateRequiredFragment() { - findNavController() - .navigate(R.id.action_migrationNavigationFragment_to_migrationAppUpdateNeededFragment) + navigationUtils.navigate( + this, R.id.action_migrationNavigationFragment_to_migrationAppUpdateNeededFragment) } private fun showModuleUpdateRequiredFragment() { - findNavController() - .navigate( - R.id.action_migrationNavigationFragment_to_migrationModuleUpdateNeededFragment) + navigationUtils.navigate( + this, R.id.action_migrationNavigationFragment_to_migrationModuleUpdateNeededFragment) } private fun showMigrationPausedFragment() { - findNavController() - .navigate(R.id.action_migrationNavigationFragment_to_migrationPausedFragment) + navigationUtils.navigate( + this, R.id.action_migrationNavigationFragment_to_migrationPausedFragment) } private fun navigateToHomeFragment() { - findNavController().navigate(R.id.action_migrationNavigationFragment_to_homeFragment) + navigationUtils.navigate(this, R.id.action_migrationNavigationFragment_to_homeFragment) } private fun markMigrationComplete() { diff --git a/apk/src/com/android/healthconnect/controller/migration/MigrationPausedFragment.kt b/apk/src/com/android/healthconnect/controller/migration/MigrationPausedFragment.kt index e98a57e5..d55ca6fd 100644 --- a/apk/src/com/android/healthconnect/controller/migration/MigrationPausedFragment.kt +++ b/apk/src/com/android/healthconnect/controller/migration/MigrationPausedFragment.kt @@ -24,8 +24,8 @@ import android.view.ViewGroup import android.widget.Button import android.widget.Toast import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController import com.android.healthconnect.controller.R +import com.android.healthconnect.controller.utils.NavigationUtils import com.android.healthconnect.controller.utils.logging.HealthConnectLogger import com.android.healthconnect.controller.utils.logging.MigrationElement import com.android.healthconnect.controller.utils.logging.PageName @@ -36,6 +36,7 @@ import javax.inject.Inject class MigrationPausedFragment : Hilt_MigrationPausedFragment() { @Inject lateinit var logger: HealthConnectLogger + @Inject lateinit var navigationUtils: NavigationUtils companion object { private const val TAG = "MigrationPausedFragment" @@ -60,7 +61,7 @@ class MigrationPausedFragment : Hilt_MigrationPausedFragment() { resumeButton.setOnClickListener { logger.logInteraction(MigrationElement.MIGRATION_PAUSED_CONTINUE_BUTTON) try { - findNavController().navigate(R.id.action_migrationPausedFragment_to_migrationApk) + navigationUtils.navigate(this, R.id.action_migrationPausedFragment_to_migrationApk) } catch (exception: Exception) { Log.e(TAG, "Migration APK does not exist", exception) Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT).show() @@ -70,21 +71,19 @@ class MigrationPausedFragment : Hilt_MigrationPausedFragment() { cancelButton.setOnClickListener { logger.logInteraction(MigrationElement.MIGRATION_UPDATE_NEEDED_CANCEL_BUTTON) val sharedPreferences = - requireActivity() - .getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) + requireActivity() + .getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) val integrationPausedSeen = - sharedPreferences.getBoolean(INTEGRATION_PAUSED_SEEN_KEY, false) + sharedPreferences.getBoolean(INTEGRATION_PAUSED_SEEN_KEY, false) if (!integrationPausedSeen) { sharedPreferences.edit().apply { putBoolean(INTEGRATION_PAUSED_SEEN_KEY, true) apply() } - findNavController() - .navigate(R.id.action_migrationPausedFragment_to_homeScreen) + navigationUtils.navigate(this, R.id.action_migrationPausedFragment_to_homeScreen) } requireActivity().finish() } - } override fun onResume() { diff --git a/apk/src/com/android/healthconnect/controller/migration/ModuleUpdateRequiredFragment.kt b/apk/src/com/android/healthconnect/controller/migration/ModuleUpdateRequiredFragment.kt index c713f28f..b5f144b5 100644 --- a/apk/src/com/android/healthconnect/controller/migration/ModuleUpdateRequiredFragment.kt +++ b/apk/src/com/android/healthconnect/controller/migration/ModuleUpdateRequiredFragment.kt @@ -27,6 +27,7 @@ import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.android.healthconnect.controller.R +import com.android.healthconnect.controller.utils.NavigationUtils import com.android.healthconnect.controller.utils.logging.HealthConnectLogger import com.android.healthconnect.controller.utils.logging.MigrationElement import com.android.healthconnect.controller.utils.logging.PageName @@ -37,6 +38,7 @@ import javax.inject.Inject class ModuleUpdateRequiredFragment : Hilt_ModuleUpdateRequiredFragment() { @Inject lateinit var logger: HealthConnectLogger + @Inject lateinit var navigationUtils: NavigationUtils companion object { private const val TAG = "ModuleUpdateRequiredFragment" @@ -76,9 +78,8 @@ class ModuleUpdateRequiredFragment : Hilt_ModuleUpdateRequiredFragment() { updateButton.setOnClickListener { logger.logInteraction(MigrationElement.MIGRATION_UPDATE_NEEDED_UPDATE_BUTTON) try { - findNavController() - .navigate( - R.id.action_migrationModuleUpdateNeededFragment_to_systemUpdateActivity) + navigationUtils.navigate( + this, R.id.action_migrationModuleUpdateNeededFragment_to_systemUpdateActivity) } catch (exception: Exception) { Log.e(TAG, "System update activity does not exist", exception) Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT).show() @@ -98,8 +99,8 @@ class ModuleUpdateRequiredFragment : Hilt_ModuleUpdateRequiredFragment() { putBoolean(getString(R.string.module_update_needed_seen), true) apply() } - findNavController() - .navigate(R.id.action_migrationModuleUpdateNeededFragment_to_homeScreen) + navigationUtils.navigate( + this, R.id.action_migrationModuleUpdateNeededFragment_to_homeScreen) } requireActivity().finish() diff --git a/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivity.kt b/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivity.kt index 6bf4ae38..3f7528d1 100644 --- a/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivity.kt +++ b/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivity.kt @@ -2,6 +2,7 @@ package com.android.healthconnect.controller.onboarding import android.app.Activity import android.content.Context +import android.content.Intent import android.os.Bundle import android.widget.Button import androidx.fragment.app.FragmentActivity @@ -22,7 +23,7 @@ class OnboardingActivity : Hilt_OnboardingActivity() { @VisibleForTesting const val USER_ACTIVITY_TRACKER = "USER_ACTIVITY_TRACKER" @VisibleForTesting const val ONBOARDING_SHOWN_PREF_KEY = "ONBOARDING_SHOWN_PREF_KEY" - fun maybeRedirectToOnboardingActivity(activity: Activity): Boolean { + fun shouldRedirectToOnboardingActivity(activity: Activity): Boolean { val sharedPreference = activity.getSharedPreferences(USER_ACTIVITY_TRACKER, Context.MODE_PRIVATE) val previouslyOpened = sharedPreference.getBoolean(ONBOARDING_SHOWN_PREF_KEY, false) @@ -31,6 +32,11 @@ class OnboardingActivity : Hilt_OnboardingActivity() { } return false } + + fun createIntent(context: Context): Intent { + return Intent(context, OnboardingActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } } @Inject lateinit var logger: HealthConnectLogger diff --git a/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivityContract.kt b/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivityContract.kt deleted file mode 100644 index 70c883d7..00000000 --- a/apk/src/com/android/healthconnect/controller/onboarding/OnboardingActivityContract.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.android.healthconnect.controller.onboarding - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.activity.result.contract.ActivityResultContract - -class OnboardingActivityContract : ActivityResultContract<Int, String?>() { - - companion object { - const val INTENT_RESULT_CANCELLED = "CANCELLED" - } - - override fun createIntent(context: Context, input: Int): Intent { - return Intent(context, OnboardingActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - override fun parseResult(resultCode: Int, intent: Intent?): String? { - return when (resultCode) { - Activity.RESULT_CANCELED -> INTENT_RESULT_CANCELLED - else -> null - } - } -} diff --git a/apk/src/com/android/healthconnect/controller/permissions/api/GetGrantedHealthPermissionsUseCase.kt b/apk/src/com/android/healthconnect/controller/permissions/api/GetGrantedHealthPermissionsUseCase.kt index 9b0ae076..75420417 100644 --- a/apk/src/com/android/healthconnect/controller/permissions/api/GetGrantedHealthPermissionsUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/permissions/api/GetGrantedHealthPermissionsUseCase.kt @@ -23,11 +23,13 @@ import javax.inject.Singleton @Singleton class GetGrantedHealthPermissionsUseCase @Inject -constructor(private val healthPermissionManager: HealthPermissionManager) { +constructor(private val healthPermissionManager: HealthPermissionManager) : + IGetGrantedHealthPermissionsUseCase { companion object { private const val TAG = "GetGrantedHealthPermiss" } - operator fun invoke(packageName: String): List<String> { + + override operator fun invoke(packageName: String): List<String> { return try { healthPermissionManager.getGrantedHealthPermissions(packageName) } catch (ex: Exception) { @@ -36,3 +38,7 @@ constructor(private val healthPermissionManager: HealthPermissionManager) { } } } + +interface IGetGrantedHealthPermissionsUseCase { + operator fun invoke(packageName: String): List<String> +} diff --git a/apk/src/com/android/healthconnect/controller/permissions/api/LoadAccessDateUseCase.kt b/apk/src/com/android/healthconnect/controller/permissions/api/LoadAccessDateUseCase.kt index ca6aad37..89e601f2 100644 --- a/apk/src/com/android/healthconnect/controller/permissions/api/LoadAccessDateUseCase.kt +++ b/apk/src/com/android/healthconnect/controller/permissions/api/LoadAccessDateUseCase.kt @@ -32,7 +32,7 @@ constructor(private val healthPermissionManager: HealthPermissionManager) { return try { healthPermissionManager.loadStartAccessDate(it) } catch (ex: Exception) { - Log.e(TAG, "GetGrantedHealthPermissionsUseCase.invoke", ex) + Log.e(TAG, "LoadStartAccessDate failed", ex) null } } diff --git a/apk/src/com/android/healthconnect/controller/permissions/request/PermissionsActivity.kt b/apk/src/com/android/healthconnect/controller/permissions/request/PermissionsActivity.kt index 9dcccfd5..066c5695 100644 --- a/apk/src/com/android/healthconnect/controller/permissions/request/PermissionsActivity.kt +++ b/apk/src/com/android/healthconnect/controller/permissions/request/PermissionsActivity.kt @@ -18,7 +18,7 @@ package com.android.healthconnect.controller.permissions.request -import android.app.Activity +import android.app.Activity.* import android.content.Intent import android.content.Intent.EXTRA_PACKAGE_NAME import android.content.pm.PackageManager @@ -27,6 +27,7 @@ import android.content.pm.PackageManager.EXTRA_REQUEST_PERMISSIONS_RESULTS import android.os.Bundle import android.util.Log import android.view.View +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels import androidx.fragment.app.FragmentActivity import com.android.healthconnect.controller.R @@ -35,9 +36,8 @@ import com.android.healthconnect.controller.migration.MigrationActivity.Companio import com.android.healthconnect.controller.migration.MigrationActivity.Companion.showMigrationPendingDialog import com.android.healthconnect.controller.migration.MigrationViewModel import com.android.healthconnect.controller.migration.api.MigrationState -import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.maybeRedirectToOnboardingActivity -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract.Companion.INTENT_RESULT_CANCELLED +import com.android.healthconnect.controller.onboarding.OnboardingActivity +import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.shouldRedirectToOnboardingActivity import com.android.healthconnect.controller.permissions.data.HealthPermission import com.android.healthconnect.controller.permissions.data.PermissionState import com.android.healthconnect.controller.shared.HealthPermissionReader @@ -57,10 +57,20 @@ class PermissionsActivity : Hilt_PermissionsActivity() { } @Inject lateinit var logger: HealthConnectLogger - private val viewModel: RequestPermissionViewModel by viewModels() - private val migrationViewModel: MigrationViewModel by viewModels() + @Inject lateinit var healthPermissionReader: HealthPermissionReader + private val requestPermissionsViewModel: RequestPermissionViewModel by viewModels() + + private val migrationViewModel: MigrationViewModel by viewModels() + + private val openOnboardingActivity = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_CANCELED) { + finish() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,8 +85,8 @@ class PermissionsActivity : Hilt_PermissionsActivity() { return } - if (maybeRedirectToOnboardingActivity(this) && savedInstanceState == null) { - openOnboardingActivity.launch(1) + if (savedInstanceState == null && shouldRedirectToOnboardingActivity(this)) { + openOnboardingActivity.launch(OnboardingActivity.createIntent(this)) } val rationalIntentDeclared = @@ -86,10 +96,10 @@ class PermissionsActivity : Hilt_PermissionsActivity() { finish() } - viewModel.init(getPackageNameExtra(), getPermissionStrings()) - viewModel.permissionsList.observe(this) { notGrantedPermissions -> + requestPermissionsViewModel.init(getPackageNameExtra(), getPermissionStrings()) + requestPermissionsViewModel.permissionsList.observe(this) { notGrantedPermissions -> if (notGrantedPermissions.isEmpty()) { - handleResults(viewModel.request(getPackageNameExtra())) + handleResults(requestPermissionsViewModel.request(getPackageNameExtra())) } } migrationViewModel.migrationState.observe(this) { migrationState -> @@ -124,8 +134,8 @@ class PermissionsActivity : Hilt_PermissionsActivity() { cancelButton.setOnClickListener { logger.logInteraction(PermissionsElement.CANCEL_PERMISSIONS_BUTTON) - viewModel.updatePermissions(false) - handleResults(viewModel.request(getPackageNameExtra())) + requestPermissionsViewModel.updatePermissions(false) + handleResults(requestPermissionsViewModel.request(getPackageNameExtra())) } } @@ -136,12 +146,12 @@ class PermissionsActivity : Hilt_PermissionsActivity() { val parentView = allowButton.parent.parent as View increaseViewTouchTargetSize(this, allowButton, parentView) - viewModel.grantedPermissions.observe(this) { grantedPermissions -> + requestPermissionsViewModel.grantedPermissions.observe(this) { grantedPermissions -> allowButton.isEnabled = grantedPermissions.isNotEmpty() } allowButton.setOnClickListener { logger.logInteraction(PermissionsElement.ALLOW_PERMISSIONS_BUTTON) - handleResults(viewModel.request(getPackageNameExtra())) + handleResults(requestPermissionsViewModel.request(getPackageNameExtra())) } } @@ -152,7 +162,7 @@ class PermissionsActivity : Hilt_PermissionsActivity() { this, getString( R.string.migration_in_progress_permissions_dialog_content, - viewModel.appMetadata.value?.appName)) { _, _ -> + requestPermissionsViewModel.appMetadata.value?.appName)) { _, _ -> finish() } } @@ -164,11 +174,11 @@ class PermissionsActivity : Hilt_PermissionsActivity() { this, getString( R.string.migration_pending_permissions_dialog_content, - viewModel.appMetadata.value?.appName), + requestPermissionsViewModel.appMetadata.value?.appName), null, ) { _, _ -> - viewModel.updatePermissions(false) - handleResults(viewModel.request(getPackageNameExtra())) + requestPermissionsViewModel.updatePermissions(false) + handleResults(requestPermissionsViewModel.request(getPackageNameExtra())) finish() } } @@ -195,7 +205,7 @@ class PermissionsActivity : Hilt_PermissionsActivity() { val result = Intent() result.putExtra(EXTRA_REQUEST_PERMISSIONS_NAMES, getPermissionStrings()) result.putExtra(EXTRA_REQUEST_PERMISSIONS_RESULTS, grants) - setResult(Activity.RESULT_OK, result) + setResult(RESULT_OK, result) finish() } @@ -206,11 +216,4 @@ class PermissionsActivity : Hilt_PermissionsActivity() { private fun getPackageNameExtra(): String { return intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty() } - - val openOnboardingActivity = - registerForActivityResult(OnboardingActivityContract()) { result -> - if (result == INTENT_RESULT_CANCELLED) { - finish() - } - } } diff --git a/apk/src/com/android/healthconnect/controller/permissions/shared/SettingsActivity.kt b/apk/src/com/android/healthconnect/controller/permissions/shared/SettingsActivity.kt index 7a4561a7..63f226f6 100644 --- a/apk/src/com/android/healthconnect/controller/permissions/shared/SettingsActivity.kt +++ b/apk/src/com/android/healthconnect/controller/permissions/shared/SettingsActivity.kt @@ -35,15 +35,15 @@ package com.android.healthconnect.controller.permissions.shared import android.content.Intent.EXTRA_PACKAGE_NAME import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.navigation.Navigation import androidx.navigation.findNavController import com.android.healthconnect.controller.R import com.android.healthconnect.controller.navigation.DestinationChangedListener -import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.maybeRedirectToOnboardingActivity -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract -import com.android.healthconnect.controller.onboarding.OnboardingActivityContract.Companion.INTENT_RESULT_CANCELLED +import com.android.healthconnect.controller.onboarding.OnboardingActivity +import com.android.healthconnect.controller.onboarding.OnboardingActivity.Companion.shouldRedirectToOnboardingActivity import com.android.healthconnect.controller.permissions.app.AppPermissionViewModel import com.android.healthconnect.controller.shared.HealthPermissionReader import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity @@ -53,20 +53,24 @@ import javax.inject.Inject @AndroidEntryPoint(CollapsingToolbarBaseActivity::class) class SettingsActivity : Hilt_SettingsActivity() { - companion object { - private const val TAG = "SettingsActivity" - } - @Inject lateinit var healthPermissionReader: HealthPermissionReader + private val viewModel: AppPermissionViewModel by viewModels() + private val openOnboardingActivity = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_CANCELED) { + finish() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) setTitle(R.string.permgrouplab_health) - if (maybeRedirectToOnboardingActivity(this) && savedInstanceState == null) { - openOnboardingActivity.launch(1) + if (savedInstanceState == null && shouldRedirectToOnboardingActivity(this)) { + openOnboardingActivity.launch(OnboardingActivity.createIntent(this)) } } @@ -74,8 +78,7 @@ class SettingsActivity : Hilt_SettingsActivity() { super.onStart() if (intent.hasExtra(EXTRA_PACKAGE_NAME)) { - val packageName = intent.getStringExtra( - EXTRA_PACKAGE_NAME)!! + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME)!! viewModel.shouldNavigateToFragment.observe(this) { shouldNavigate -> maybeNavigateToFragment(shouldNavigate) @@ -83,7 +86,6 @@ class SettingsActivity : Hilt_SettingsActivity() { viewModel.loadShouldNavigateToFragment(packageName) } - } private fun maybeNavigateToFragment(shouldNavigate: Boolean) { @@ -93,8 +95,7 @@ class SettingsActivity : Hilt_SettingsActivity() { navController.navigate( R.id.action_deeplink_to_settingsManageAppPermissionsFragment, bundleOf(EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME))) - } - else { + } else { finish() } } @@ -113,11 +114,4 @@ class SettingsActivity : Hilt_SettingsActivity() { } return true } - - val openOnboardingActivity = - registerForActivityResult(OnboardingActivityContract()) { result -> - if (result == INTENT_RESULT_CANCELLED) { - finish() - } - } } diff --git a/apk/src/com/android/healthconnect/controller/permissiontypes/HealthPermissionTypesFragment.kt b/apk/src/com/android/healthconnect/controller/permissiontypes/HealthPermissionTypesFragment.kt index b550616a..c20c5512 100644 --- a/apk/src/com/android/healthconnect/controller/permissiontypes/HealthPermissionTypesFragment.kt +++ b/apk/src/com/android/healthconnect/controller/permissiontypes/HealthPermissionTypesFragment.kt @@ -78,8 +78,7 @@ open class HealthPermissionTypesFragment : Hilt_HealthPermissionTypesFragment() } @Inject lateinit var logger: HealthConnectLogger - @Inject - lateinit var featureUtils: FeatureUtils + @Inject lateinit var featureUtils: FeatureUtils @HealthDataCategoryInt private var category: Int = 0 @@ -188,14 +187,45 @@ open class HealthPermissionTypesFragment : Hilt_HealthPermissionTypesFragment() mManageDataCategory?.removePreferenceRecursively(APP_PRIORITY_BUTTON) } is HealthPermissionTypesViewModel.PriorityListState.WithData -> { - updatePriorityButton(state.priorityList) + updateOldPriorityButton(state.priorityList) } } } + } else { + // Add the new priority list button + updateNewPriorityButton() } } - private fun updatePriorityButton(priorityList: List<AppMetadata>) { + private fun updateNewPriorityButton() { + mManageDataCategory?.removePreferenceRecursively(APP_PRIORITY_BUTTON) + + // Only display the priority button for Activity and Sleep categories + if (category !in setOf(HealthDataCategory.ACTIVITY, HealthDataCategory.SLEEP)) { + return + } + + val newPriorityButton = + HealthPreference(requireContext()).also { + it.title = resources.getString(R.string.data_sources_and_priority_title) + it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.appPriorityIcon) + it.logName = PermissionTypesElement.DATA_SOURCES_AND_PRIORITY_BUTTON + it.key = APP_PRIORITY_BUTTON + it.order = 4 + it.setOnPreferenceClickListener { + // Navigate to the data sources fragment + findNavController() + .navigate( + R.id.action_healthPermissionTypes_to_dataSourcesAndPriority, + bundleOf(CATEGORY_KEY to category)) + true + } + } + + mManageDataCategory?.addPreference(newPriorityButton) + } + + private fun updateOldPriorityButton(priorityList: List<AppMetadata>) { mManageDataCategory?.removePreferenceRecursively(APP_PRIORITY_BUTTON) // Only display the priority button for Activity and Sleep categories @@ -210,8 +240,7 @@ open class HealthPermissionTypesFragment : Hilt_HealthPermissionTypesFragment() val appPriorityButton = HealthPreference(requireContext()).also { it.title = resources.getString(R.string.app_priority_button) - it.icon = - AttributeResolver.getDrawable(requireContext(), R.attr.appPriorityIcon) + it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.appPriorityIcon) it.logName = PermissionTypesElement.SET_APP_PRIORITY_BUTTON it.summary = priorityList.first().appName it.key = APP_PRIORITY_BUTTON diff --git a/apk/src/com/android/healthconnect/controller/service/UseCaseModule.kt b/apk/src/com/android/healthconnect/controller/service/UseCaseModule.kt index ebe43643..5d18819a 100644 --- a/apk/src/com/android/healthconnect/controller/service/UseCaseModule.kt +++ b/apk/src/com/android/healthconnect/controller/service/UseCaseModule.kt @@ -16,6 +16,10 @@ package com.android.healthconnect.controller.service import android.health.connect.HealthConnectManager +import com.android.healthconnect.controller.data.access.ILoadAccessUseCase +import com.android.healthconnect.controller.data.access.ILoadPermissionTypeContributorAppsUseCase +import com.android.healthconnect.controller.data.access.LoadAccessUseCase +import com.android.healthconnect.controller.data.access.LoadPermissionTypeContributorAppsUseCase import com.android.healthconnect.controller.data.entries.api.ILoadDataAggregationsUseCase import com.android.healthconnect.controller.data.entries.api.ILoadDataEntriesUseCase import com.android.healthconnect.controller.data.entries.api.ILoadMenstruationDataUseCase @@ -23,19 +27,26 @@ import com.android.healthconnect.controller.data.entries.api.LoadDataAggregation import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesUseCase import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper import com.android.healthconnect.controller.data.entries.api.LoadMenstruationDataUseCase -import com.android.healthconnect.controller.data.entries.api.LoadSleepDataUseCase import com.android.healthconnect.controller.dataentries.formatters.DistanceFormatter import com.android.healthconnect.controller.dataentries.formatters.MenstruationPeriodFormatter import com.android.healthconnect.controller.dataentries.formatters.SleepSessionFormatter import com.android.healthconnect.controller.dataentries.formatters.StepsFormatter import com.android.healthconnect.controller.dataentries.formatters.TotalCaloriesBurnedFormatter +import com.android.healthconnect.controller.datasources.api.ILoadLastDateWithPriorityDataUseCase import com.android.healthconnect.controller.datasources.api.ILoadMostRecentAggregationsUseCase import com.android.healthconnect.controller.datasources.api.ILoadPotentialPriorityListUseCase +import com.android.healthconnect.controller.datasources.api.ILoadPriorityEntriesUseCase +import com.android.healthconnect.controller.datasources.api.ISleepSessionHelper import com.android.healthconnect.controller.datasources.api.IUpdatePriorityListUseCase +import com.android.healthconnect.controller.datasources.api.LoadLastDateWithPriorityDataUseCase import com.android.healthconnect.controller.datasources.api.LoadMostRecentAggregationsUseCase import com.android.healthconnect.controller.datasources.api.LoadPotentialPriorityListUseCase +import com.android.healthconnect.controller.datasources.api.LoadPriorityEntriesUseCase +import com.android.healthconnect.controller.datasources.api.SleepSessionHelper import com.android.healthconnect.controller.datasources.api.UpdatePriorityListUseCase import com.android.healthconnect.controller.permissions.api.GetGrantedHealthPermissionsUseCase +import com.android.healthconnect.controller.permissions.api.HealthPermissionManager +import com.android.healthconnect.controller.permissions.api.IGetGrantedHealthPermissionsUseCase import com.android.healthconnect.controller.permissions.connectedapps.ILoadHealthPermissionApps import com.android.healthconnect.controller.permissions.connectedapps.LoadHealthPermissionApps import com.android.healthconnect.controller.permissions.shared.QueryRecentAccessLogsUseCase @@ -125,13 +136,33 @@ class UseCaseModule { @Provides fun providesMostRecentAggregationsUseCase( - healthConnectManager: HealthConnectManager, loadDataAggregationsUseCase: LoadDataAggregationsUseCase, - sleepDataUseCase: LoadSleepDataUseCase, + loadLastDateWithPriorityDataUseCase: LoadLastDateWithPriorityDataUseCase, + sleepSessionHelper: SleepSessionHelper, @IoDispatcher dispatcher: CoroutineDispatcher ): ILoadMostRecentAggregationsUseCase { return LoadMostRecentAggregationsUseCase( - healthConnectManager, loadDataAggregationsUseCase, sleepDataUseCase, dispatcher) + loadDataAggregationsUseCase, + loadLastDateWithPriorityDataUseCase, + sleepSessionHelper, + dispatcher) + } + + @Provides + fun providesSleepSessionHelper( + loadPriorityEntriesUseCase: LoadPriorityEntriesUseCase, + @IoDispatcher dispatcher: CoroutineDispatcher + ): ISleepSessionHelper { + return SleepSessionHelper(loadPriorityEntriesUseCase, dispatcher) + } + + @Provides + fun providesLoadPriorityEntriesUseCase( + loadEntriesHelper: LoadEntriesHelper, + loadPriorityListUseCase: LoadPriorityListUseCase, + @IoDispatcher dispatcher: CoroutineDispatcher + ): ILoadPriorityEntriesUseCase { + return LoadPriorityEntriesUseCase(loadEntriesHelper, loadPriorityListUseCase, dispatcher) } @Provides @@ -153,6 +184,22 @@ class UseCaseModule { } @Provides + fun providesLoadLastDateWithPriorityDataUseCase( + healthConnectManager: HealthConnectManager, + loadEntriesHelper: LoadEntriesHelper, + loadPriorityListUseCase: LoadPriorityListUseCase, + timeSource: TimeSource, + @IoDispatcher dispatcher: CoroutineDispatcher + ): ILoadLastDateWithPriorityDataUseCase { + return LoadLastDateWithPriorityDataUseCase( + healthConnectManager, + loadEntriesHelper, + loadPriorityListUseCase, + timeSource, + dispatcher) + } + + @Provides fun providesPriorityListUseCase( appInfoReader: AppInfoReader, healthConnectManager: HealthConnectManager, @@ -168,4 +215,37 @@ class UseCaseModule { ): IUpdatePriorityListUseCase { return UpdatePriorityListUseCase(healthConnectManager, dispatcher) } + + @Provides + fun providesLoadAccessUseCase( + loadPermissionTypeContributorAppsUseCase: ILoadPermissionTypeContributorAppsUseCase, + loadGrantedHealthPermissionsUseCase: IGetGrantedHealthPermissionsUseCase, + healthPermissionReader: HealthPermissionReader, + appInfoReader: AppInfoReader, + @IoDispatcher dispatcher: CoroutineDispatcher + ): ILoadAccessUseCase { + return LoadAccessUseCase( + loadPermissionTypeContributorAppsUseCase, + loadGrantedHealthPermissionsUseCase, + healthPermissionReader, + appInfoReader, + dispatcher) + } + + @Provides + fun providesLoadPermissionTypeContributorAppsUseCase( + appInfoReader: AppInfoReader, + healthConnectManager: HealthConnectManager, + @IoDispatcher dispatcher: CoroutineDispatcher + ): ILoadPermissionTypeContributorAppsUseCase { + return LoadPermissionTypeContributorAppsUseCase( + appInfoReader, healthConnectManager, dispatcher) + } + + @Provides + fun providesGetGrantedHealthPermissionsUseCase( + healthPermissionManager: HealthPermissionManager + ): IGetGrantedHealthPermissionsUseCase { + return GetGrantedHealthPermissionsUseCase(healthPermissionManager) + } } diff --git a/apk/src/com/android/healthconnect/controller/shared/preference/AggregationDataCard.kt b/apk/src/com/android/healthconnect/controller/shared/preference/AggregationDataCard.kt index 719e806a..26cb5106 100644 --- a/apk/src/com/android/healthconnect/controller/shared/preference/AggregationDataCard.kt +++ b/apk/src/com/android/healthconnect/controller/shared/preference/AggregationDataCard.kt @@ -28,7 +28,9 @@ import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions. import com.android.healthconnect.controller.utils.LocalDateTimeFormatter import com.android.healthconnect.controller.utils.SystemTimeSource import com.android.healthconnect.controller.utils.TimeSource +import com.android.healthconnect.controller.utils.toLocalTime import java.time.Instant +import java.time.LocalTime /** A custom card to display the latest available data aggregations. */ class AggregationDataCard @@ -122,11 +124,18 @@ constructor( private fun formatDateText(startDate: Instant, endDate: Instant?): String { return if (endDate != null) { + var localEndDate: Instant = endDate + + // If endDate is midnight, add one millisecond so that DateUtils + // correctly formats it as a separate date. + if (endDate.toLocalTime() == LocalTime.MIDNIGHT) { + localEndDate = endDate.plusMillis(1) + } // display date range - if (isLessThanOneYearAgo(startDate) && isLessThanOneYearAgo(endDate)) { - dateFormatter.formatDateRangeWithoutYear(startDate, endDate) + if (isLessThanOneYearAgo(startDate) && isLessThanOneYearAgo(localEndDate)) { + dateFormatter.formatDateRangeWithoutYear(startDate, localEndDate) } else { - dateFormatter.formatDateRangeWithYear(startDate, endDate) + dateFormatter.formatDateRangeWithYear(startDate, localEndDate) } } else { // display only one date diff --git a/apk/src/com/android/healthconnect/controller/shared/preference/CardContainerPreference.kt b/apk/src/com/android/healthconnect/controller/shared/preference/CardContainerPreference.kt index 0e6034ec..9bf17be6 100644 --- a/apk/src/com/android/healthconnect/controller/shared/preference/CardContainerPreference.kt +++ b/apk/src/com/android/healthconnect/controller/shared/preference/CardContainerPreference.kt @@ -26,15 +26,26 @@ import com.android.healthconnect.controller.datasources.AggregationCardInfo import com.android.healthconnect.controller.permissions.connectedapps.ComparablePreference import com.android.healthconnect.controller.utils.SystemTimeSource import com.android.healthconnect.controller.utils.TimeSource +import com.android.healthconnect.controller.utils.logging.DataSourcesElement +import com.android.healthconnect.controller.utils.logging.ElementName +import com.android.healthconnect.controller.utils.logging.HealthConnectLogger +import com.android.healthconnect.controller.utils.logging.HealthConnectLoggerEntryPoint +import dagger.hilt.android.EntryPointAccessors -class CardContainerPreference constructor( - context: Context, - private val timeSource: TimeSource = SystemTimeSource -): Preference(context), ComparablePreference { +class CardContainerPreference +constructor(context: Context, private val timeSource: TimeSource = SystemTimeSource) : + Preference(context), ComparablePreference { + + private var logger: HealthConnectLogger + var logName: ElementName = DataSourcesElement.DATA_TOTALS_CARD init { layoutResource = R.layout.widget_card_preference isSelectable = false + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + context.applicationContext, HealthConnectLoggerEntryPoint::class.java) + logger = hiltEntryPoint.logger() } private val mAggregationCardInfo: MutableList<AggregationCardInfo> = mutableListOf() @@ -52,7 +63,6 @@ class CardContainerPreference constructor( // We display a max of 2 cards, so we take the first two list items if (aggregationCardInfoList.size > 2) { this.mAggregationCardInfo.addAll(aggregationCardInfoList.subList(0, 2)) - } else { this.mAggregationCardInfo.addAll(aggregationCardInfoList) } @@ -65,8 +75,7 @@ class CardContainerPreference constructor( } if (!isLoading) { - holder?.let { - onBindViewHolder(it) } + holder?.let { onBindViewHolder(it) } } else { // Get the current width and height on the card container so we don't flash the screen val width = container?.width @@ -77,9 +86,10 @@ class CardContainerPreference constructor( progressBar = layoutInflater.inflate(R.layout.widget_loading_preference, null) as ConstraintLayout - val layoutParams = ConstraintLayout.LayoutParams( - width ?: ConstraintLayout.LayoutParams.WRAP_CONTENT, - height ?: ConstraintLayout.LayoutParams.WRAP_CONTENT) + val layoutParams = + ConstraintLayout.LayoutParams( + width ?: ConstraintLayout.LayoutParams.WRAP_CONTENT, + height ?: ConstraintLayout.LayoutParams.WRAP_CONTENT) progressBar?.layoutParams = layoutParams container?.addView(progressBar) } @@ -96,6 +106,7 @@ class CardContainerPreference constructor( setLoading(true) } + logger.logImpression(logName) } private fun setupCards() { @@ -109,14 +120,13 @@ class CardContainerPreference constructor( } if (mAggregationCardInfo.size == 1) { - addSingleLargeCard(mAggregationCardInfo[0]) - container?.removeView(progressBar) + val card = addSingleLargeCard(mAggregationCardInfo[0]) + removeAllChildrenExcept(container, card) } else { // Add both types of cards to the container (they will be invisible) val (firstSmallCard, secondSmallCard) = - addTwoSmallCards(mAggregationCardInfo[0], - mAggregationCardInfo[1]) + addTwoSmallCards(mAggregationCardInfo[0], mAggregationCardInfo[1]) val (firstLargeCard, secondLargeCard) = addTwoLargeCards(mAggregationCardInfo[0], mAggregationCardInfo[1]) @@ -153,31 +163,31 @@ class CardContainerPreference constructor( } /** - * Adds a single large [AggregationDataCard] to the provided container. - * This should be called when there is only one available aggregate. + * Adds a single large [AggregationDataCard] to the provided container. This should be called + * when there is only one available aggregate. */ - private fun addSingleLargeCard(cardInfo: AggregationCardInfo) { - val singleCard = AggregationDataCard( - context, - null, - AggregationDataCard.CardTypeEnum.LARGE_CARD, - cardInfo, - timeSource) + private fun addSingleLargeCard(cardInfo: AggregationCardInfo): AggregationDataCard { + val singleCard = + AggregationDataCard( + context, null, AggregationDataCard.CardTypeEnum.LARGE_CARD, cardInfo, timeSource) singleCard.id = View.generateViewId() - val layoutParams = ConstraintLayout.LayoutParams( + val layoutParams = + ConstraintLayout.LayoutParams( ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.WRAP_CONTENT) singleCard.layoutParams = layoutParams container?.addView(singleCard) + return singleCard } /** - * Adds two small [AggregationDataCard]s to the provided container stacked horizontally. - * This should be called when there are two available aggregates. + * Adds two small [AggregationDataCard]s to the provided container stacked horizontally. This + * should be called when there are two available aggregates. */ private fun addTwoSmallCards( firstCardInfo: AggregationCardInfo, - secondCardInfo: AggregationCardInfo): Pair<AggregationDataCard, AggregationDataCard> { + secondCardInfo: AggregationCardInfo + ): Pair<AggregationDataCard, AggregationDataCard> { // Construct the first card val firstCard = constructSmallCard(firstCardInfo, addMargin = true) @@ -203,38 +213,42 @@ class CardContainerPreference constructor( constraintSet.clone(container) // Constraints for the first card - constraintSet.connect(firstCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) - constraintSet.connect(firstCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) - constraintSet.connect(firstCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + constraintSet.connect( + firstCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + constraintSet.connect( + firstCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + constraintSet.connect( + firstCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) constraintSet.connect(firstCard.id, ConstraintSet.END, secondCard.id, ConstraintSet.START) // Constraints for the second card constraintSet.connect(secondCard.id, ConstraintSet.START, firstCard.id, ConstraintSet.END) - constraintSet.connect(secondCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) - constraintSet.connect(secondCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) - constraintSet.connect(secondCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + constraintSet.connect( + secondCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + constraintSet.connect( + secondCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + constraintSet.connect( + secondCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) constraintSet.applyTo(container) } private fun constructSmallCard( cardInfo: AggregationCardInfo, - addMargin: Boolean) : AggregationDataCard { - val card = AggregationDataCard( - context, - null, - AggregationDataCard.CardTypeEnum.SMALL_CARD, - cardInfo, - timeSource) + addMargin: Boolean + ): AggregationDataCard { + val card = + AggregationDataCard( + context, null, AggregationDataCard.CardTypeEnum.SMALL_CARD, cardInfo, timeSource) card.id = View.generateViewId() - val layoutParams = ConstraintLayout.LayoutParams(0, - ConstraintLayout.LayoutParams.WRAP_CONTENT) + val layoutParams = + ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT) if (addMargin) { // Set a right margin of 16dp for the first (leftmost) card val marginInDp = 16 val marginInPx = (marginInDp * context.resources.displayMetrics.density).toInt() - layoutParams.setMargins(0,0, marginInPx, 0) + layoutParams.setMargins(0, 0, marginInPx, 0) } card.layoutParams = layoutParams @@ -243,13 +257,14 @@ class CardContainerPreference constructor( } /** - * Adds two large [AggregationDataCard]s to the provided container stacked vertically. - * This should be called when there are two available aggregates and the text is - * too large to fit into small cards. + * Adds two large [AggregationDataCard]s to the provided container stacked vertically. This + * should be called when there are two available aggregates and the text is too large to fit + * into small cards. */ private fun addTwoLargeCards( firstCardInfo: AggregationCardInfo, - secondCardInfo: AggregationCardInfo): Pair<AggregationDataCard, AggregationDataCard> { + secondCardInfo: AggregationCardInfo + ): Pair<AggregationDataCard, AggregationDataCard> { // Construct the first card val firstLongCard = constructLargeCard(firstCardInfo, addMargin = true) // Construct the second card @@ -275,16 +290,22 @@ class CardContainerPreference constructor( constraintSet.clone(container) // Constraints for the first card - constraintSet.connect(firstCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) - constraintSet.connect(firstCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + constraintSet.connect( + firstCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + constraintSet.connect( + firstCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) constraintSet.connect(firstCard.id, ConstraintSet.BOTTOM, secondCard.id, ConstraintSet.TOP) - constraintSet.connect(firstCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + constraintSet.connect( + firstCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) // Constraints for the first card constraintSet.connect(secondCard.id, ConstraintSet.TOP, firstCard.id, ConstraintSet.BOTTOM) - constraintSet.connect(secondCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) - constraintSet.connect(secondCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) - constraintSet.connect(secondCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + constraintSet.connect( + secondCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + constraintSet.connect( + secondCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + constraintSet.connect( + secondCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) constraintSet.applyTo(container) } @@ -293,31 +314,31 @@ class CardContainerPreference constructor( cardInfo: AggregationCardInfo, addMargin: Boolean ): AggregationDataCard { - val largeCard = AggregationDataCard(context, null, - AggregationDataCard.CardTypeEnum.LARGE_CARD, cardInfo, timeSource) + val largeCard = + AggregationDataCard( + context, null, AggregationDataCard.CardTypeEnum.LARGE_CARD, cardInfo, timeSource) largeCard.id = View.generateViewId() - val layoutParams = ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT) + val layoutParams = + ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT) if (addMargin) { // Set a bottom margin of 16dp for the first (topmost) card val marginInDp = 16 val marginInPx = (marginInDp * context.resources.displayMetrics.density).toInt() - layoutParams.setMargins(0,0, 0, marginInPx) + layoutParams.setMargins(0, 0, 0, marginInPx) } largeCard.layoutParams = layoutParams return largeCard } - /** - * Returns true if the provided textView is ellipsized (...) - */ + /** Returns true if the provided textView is ellipsized (...) */ private fun isTextEllipsized(textView: TextView): Boolean { if (textView.layout != null) { val lines = textView.layout.lineCount if (lines > 0) { - if (textView.layout.getEllipsisCount(lines - 1) > 0 ) { + if (textView.layout.getEllipsisCount(lines - 1) > 0) { return true } } @@ -325,13 +346,23 @@ class CardContainerPreference constructor( return false } + private fun removeAllChildrenExcept(container: ConstraintLayout?, childToKeep: View) { + container?.let { + for (i in it.childCount - 1 downTo 0) { + val currentChild = it.getChildAt(i) + if (currentChild != childToKeep) { + it.removeViewAt(i) + } + } + } + } + override fun hasSameContents(preference: Preference): Boolean { return preference is CardContainerPreference && - preference.mAggregationCardInfo == this.mAggregationCardInfo + preference.mAggregationCardInfo == this.mAggregationCardInfo } override fun isSameItem(preference: Preference): Boolean { - return preference is CardContainerPreference && - this == preference + return preference is CardContainerPreference && this == preference } -}
\ No newline at end of file +} diff --git a/apk/src/com/android/healthconnect/controller/utils/AppStoreUtil.kt b/apk/src/com/android/healthconnect/controller/utils/AppStoreUtil.kt deleted file mode 100644 index 24824138..00000000 --- a/apk/src/com/android/healthconnect/controller/utils/AppStoreUtil.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.android.healthconnect.controller.utils - -import android.content.Context -import android.content.Intent -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager.NameNotFoundException -import android.util.Log - -/** Functions that help dealing with app stores. */ -private const val TAG = "HCAppStoreUtil" - -private fun resolveIntent(context: Context, intent: Intent): Intent? { - val resolveInfoResult = context.packageManager.resolveActivity(intent, 0) - return if (resolveInfoResult != null) { - Intent(intent.action) - .setClassName( - resolveInfoResult.activityInfo.packageName, resolveInfoResult.activityInfo.name) - } else null -} - -private fun getInstallerPackageName(context: Context, packageName: String): String? { - var installerPackageName: String? = null - - try { - val source = context.packageManager.getInstallSourceInfo(packageName) - - // By default use the installing package name - installerPackageName = source.installingPackageName - - // Use the recorded originating package name only if the initiating package is a system - // app (eg. Package Installer). The originating package is not verified by the platform, - // so we choose to ignore this when supplied by a non-system app. - val originatingPackageName = source.originatingPackageName - val initiatingPackageName = source.initiatingPackageName - if (originatingPackageName != null && initiatingPackageName != null) { - val ai = context.packageManager.getApplicationInfo(initiatingPackageName, 0) - if (ai.flags and ApplicationInfo.FLAG_SYSTEM != 0) { - installerPackageName = originatingPackageName - } - } - } catch (exception: NameNotFoundException) { - Log.e(TAG, "Exception while retrieving the package installer of $packageName", exception) - } - - return installerPackageName -} - -private fun getAppStoreLink( - context: Context, - installerPackageName: String?, - packageName: String -): Intent? { - val intent = Intent(Intent.ACTION_SHOW_APP_INFO) - if (installerPackageName != null) { - // if we cannot find the installer package name we can still - // send the intent which should be handled by one app - intent.setPackage(installerPackageName) - } - - val result = resolveIntent(context, intent) - if (result != null) { - result.putExtra(Intent.EXTRA_PACKAGE_NAME, packageName) - return result - } - - return null -} - -fun getAppStoreLink(context: Context, packageName: String): Intent? { - val installerPackageName = getInstallerPackageName(context, packageName) - return getAppStoreLink(context, installerPackageName, packageName) -} diff --git a/apk/src/com/android/healthconnect/controller/utils/AppStoreUtils.kt b/apk/src/com/android/healthconnect/controller/utils/AppStoreUtils.kt new file mode 100644 index 00000000..529c0e6c --- /dev/null +++ b/apk/src/com/android/healthconnect/controller/utils/AppStoreUtils.kt @@ -0,0 +1,83 @@ +package com.android.healthconnect.controller.utils + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager.NameNotFoundException +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** Functions that help dealing with app stores. */ +@Singleton +class AppStoreUtils @Inject constructor(@ApplicationContext private val context: Context) { + + companion object { + private const val TAG = "HCAppStoreUtil" + } + + private val packageManager = context.packageManager + + /** + * Returns the app store intent for a package name, returns null if the package is not installed + */ + fun getAppStoreLink(packageName: String): Intent? { + val installerPackageName = getInstallerPackageName(packageName) + return getAppStoreLink(installerPackageName, packageName) + } + + private fun getInstallerPackageName(packageName: String): String? { + var installerPackageName: String? = null + + try { + val source = packageManager.getInstallSourceInfo(packageName) + + // By default use the installing package name + installerPackageName = source.installingPackageName + + // Use the recorded originating package name only if the initiating package is a system + // app (eg. Package Installer). The originating package is not verified by the platform, + // so we choose to ignore this when supplied by a non-system app. + val originatingPackageName = source.originatingPackageName + val initiatingPackageName = source.initiatingPackageName + if (originatingPackageName != null && initiatingPackageName != null) { + val ai = packageManager.getApplicationInfo(initiatingPackageName, 0) + if (ai.flags and ApplicationInfo.FLAG_SYSTEM != 0) { + installerPackageName = originatingPackageName + } + } + } catch (exception: NameNotFoundException) { + Log.e( + TAG, "Exception while retrieving the package installer of $packageName", exception) + } + + return installerPackageName + } + + private fun getAppStoreLink(installerPackageName: String?, packageName: String): Intent? { + val intent = Intent(Intent.ACTION_SHOW_APP_INFO) + if (installerPackageName != null) { + // if we cannot find the installer package name we can still + // send the intent which should be handled by one app + intent.setPackage(installerPackageName) + } + + val result = resolveIntent(intent) + if (result != null) { + result.putExtra(Intent.EXTRA_PACKAGE_NAME, packageName) + return result + } + + return null + } + + private fun resolveIntent(intent: Intent): Intent? { + val resolveInfoResult = packageManager.resolveActivity(intent, 0) + return if (resolveInfoResult != null) { + Intent(intent.action) + .setClassName( + resolveInfoResult.activityInfo.packageName, resolveInfoResult.activityInfo.name) + } else null + } +} diff --git a/apk/src/com/android/healthconnect/controller/utils/NavigationUtils.kt b/apk/src/com/android/healthconnect/controller/utils/NavigationUtils.kt new file mode 100644 index 00000000..a791e43f --- /dev/null +++ b/apk/src/com/android/healthconnect/controller/utils/NavigationUtils.kt @@ -0,0 +1,17 @@ +package com.android.healthconnect.controller.utils + +import android.content.Intent +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import javax.inject.Inject + +class NavigationUtils @Inject constructor() { + + fun navigate(fragment: Fragment, action: Int) { + fragment.findNavController().navigate(action) + } + + fun startActivity(fragment: Fragment, intent: Intent) { + fragment.startActivity(intent) + } +} diff --git a/apk/src/com/android/healthconnect/controller/utils/TimeExtensions.kt b/apk/src/com/android/healthconnect/controller/utils/TimeExtensions.kt index 0a42689a..5fcbd39f 100644 --- a/apk/src/com/android/healthconnect/controller/utils/TimeExtensions.kt +++ b/apk/src/com/android/healthconnect/controller/utils/TimeExtensions.kt @@ -15,11 +15,14 @@ */ package com.android.healthconnect.controller.utils +import java.time.Duration import java.time.Instant import java.time.Instant.ofEpochMilli import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId +import kotlin.random.Random /** * Returns an Instant with the specified year, month and day-of-month. The day must be valid for the @@ -46,6 +49,10 @@ fun Instant.toLocalTime(): LocalTime { return atZone(ZoneId.systemDefault()).toLocalTime() } +fun Instant.toLocalDateTime(): LocalDateTime { + return atZone(ZoneId.systemDefault()).toLocalDateTime() +} + fun Instant.isOnSameDay(other: Instant): Boolean { val localDate1 = this.toLocalDate() val localDate2 = other.toLocalDate() @@ -77,3 +84,21 @@ fun Instant.isAtLeastOneDayAfter(other: Instant): Boolean { fun LocalDate.toInstantAtStartOfDay(): Instant { return this.atStartOfDay(ZoneId.systemDefault()).toInstant() } + +fun LocalDate.randomInstant(): Instant { + val startOfDay = this.toInstantAtStartOfDay() + + // Calculate the number of seconds in a day, accounting for daylight saving changes + val duration = Duration.between(startOfDay, this.plusDays(1).toInstantAtStartOfDay()) + val secondsInDay = duration.seconds + + // Generate a random offset in seconds within the day + val randomSecondOffset = Random.nextLong(secondsInDay) + + // Return the calculated instant + return startOfDay.plusSeconds(randomSecondOffset) +} + +fun LocalDateTime.toInstant(): Instant { + return atZone(ZoneId.systemDefault()).toInstant() +} diff --git a/apk/src/com/android/healthconnect/controller/utils/logging/HealthConnectLogger.kt b/apk/src/com/android/healthconnect/controller/utils/logging/HealthConnectLogger.kt index 711b1733..370f6efd 100644 --- a/apk/src/com/android/healthconnect/controller/utils/logging/HealthConnectLogger.kt +++ b/apk/src/com/android/healthconnect/controller/utils/logging/HealthConnectLogger.kt @@ -135,9 +135,14 @@ enum class PageName(val impressionId: Int, val interactionId: Int) { HEALTH_CONNECT_UI_IMPRESSION__PAGE__MIGRATION_PAUSED_PAGE, HEALTH_CONNECT_UI_INTERACTION__PAGE__MIGRATION_PAUSED_PAGE), MANAGE_DATA_PAGE( - HEALTH_CONNECT_UI_IMPRESSION__PAGE__MANAGE_DATA_PAGE, - HEALTH_CONNECT_UI_INTERACTION__PAGE__MANAGE_DATA_PAGE - ), + HEALTH_CONNECT_UI_IMPRESSION__PAGE__MANAGE_DATA_PAGE, + HEALTH_CONNECT_UI_INTERACTION__PAGE__MANAGE_DATA_PAGE), + DATA_SOURCES_PAGE( + HEALTH_CONNECT_UI_IMPRESSION__PAGE__DATA_SOURCES_PAGE, + HEALTH_CONNECT_UI_INTERACTION__PAGE__DATA_SOURCES_PAGE), + ADD_AN_APP_PAGE( + HEALTH_CONNECT_UI_IMPRESSION__PAGE__ADD_AN_APP_PAGE, + HEALTH_CONNECT_UI_INTERACTION__PAGE__ADD_AN_APP_PAGE), UNKNOWN_PAGE( HEALTH_CONNECT_UI_IMPRESSION__PAGE__PAGE_UNKNOWN, HEALTH_CONNECT_UI_INTERACTION__PAGE__PAGE_UNKNOWN) @@ -162,8 +167,8 @@ enum class HomePageElement(override val impressionId: Int, override val interact HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__SEE_ALL_RECENT_ACCESS_BUTTON, HEALTH_CONNECT_UI_INTERACTION__ELEMENT__SEE_ALL_RECENT_ACCESS_BUTTON), MANAGE_DATA_BUTTON( - HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__MANAGE_DATA_BUTTON, - HEALTH_CONNECT_UI_INTERACTION__ELEMENT__MANAGE_DATA_BUTTON), + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__MANAGE_DATA_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__MANAGE_DATA_BUTTON), } /** Loggable elements in the Onboarding page. */ @@ -188,24 +193,21 @@ enum class RecentAccessElement(override val impressionId: Int, override val inte HEALTH_CONNECT_UI_INTERACTION__ELEMENT__MANAGE_PERMISSIONS_FLOATING_BUTTON), } +/** Loggable elements in the Manage Data page. */ enum class ManageDataElement(override val impressionId: Int, override val interactionId: Int) : ElementName { AUTO_DELETE_BUTTON( - HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__AUTO_DELETE_BUTTON, - HEALTH_CONNECT_UI_INTERACTION__ELEMENT__AUTO_DELETE_BUTTON - ), + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__AUTO_DELETE_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__AUTO_DELETE_BUTTON), BACKUP_BUTTON( - HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__BACKUP_DATA_BUTTON, - HEALTH_CONNECT_UI_INTERACTION__ELEMENT__BACKUP_DATA_BUTTON - ), + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__BACKUP_DATA_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__BACKUP_DATA_BUTTON), DATA_SOURCES_AND_PRIORITY_BUTTON( - HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__DATA_SOURCES_AND_PRIORITY_BUTTON, - HEALTH_CONNECT_UI_INTERACTION__ELEMENT__DATA_SOURCES_AND_PRIORITY_BUTTON - ), + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__DATA_SOURCES_AND_PRIORITY_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__DATA_SOURCES_AND_PRIORITY_BUTTON), SET_UNITS_BUTTON( - HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__SET_UNITS_BUTTON, - HEALTH_CONNECT_UI_INTERACTION__ELEMENT__SET_UNITS_BUTTON - ) + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__SET_UNITS_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__SET_UNITS_BUTTON) } /** Loggable elements in the Category and All categories pages. */ @@ -307,6 +309,11 @@ enum class PermissionTypesElement(override val impressionId: Int, override val i SET_APP_PRIORITY_DIALOG_SAVE_BUTTON( HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__ELEMENT_UNKNOWN, HEALTH_CONNECT_UI_INTERACTION__ELEMENT__ELEMENT_UNKNOWN), + + // New app priority + DATA_SOURCES_AND_PRIORITY_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__DATA_SOURCES_AND_PRIORITY_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__DATA_SOURCES_AND_PRIORITY_BUTTON), } /** Loggable elements in the Data access page. */ @@ -679,6 +686,40 @@ enum class MigrationElement(override val impressionId: Int, override val interac HEALTH_CONNECT_UI_INTERACTION__ELEMENT__MIGRATION_APP_UPDATE_BUTTON) } +/** Loggable elements in the Data sources page. */ +enum class DataSourcesElement(override val impressionId: Int, override val interactionId: Int) : + ElementName { + DATA_TYPE_SPINNER( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__DATA_TYPE_SPINNER_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__DATA_TYPE_SPINNER_BUTTON), + DATA_TOTALS_CARD( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__DATA_TOTALS_CARD, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__DATA_TOTALS_CARD), + APP_SOURCE_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__APP_SOURCE_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__APP_SOURCE_BUTTON), + ADD_AN_APP_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__ADD_AN_APP_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__ADD_AN_APP_BUTTON), + EDIT_SOURCE_LIST_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__EDIT_SOURCE_LIST_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__EDIT_SOURCE_LIST_BUTTON), + REORDER_APP_SOURCE_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__REORDER_APP_SOURCE_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__REORDER_APP_SOURCE_BUTTON), + REMOVE_APP_SOURCE_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__REMOVE_APP_SOURCE_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__REMOVE_APP_SOURCE_BUTTON) +} + +/** Loggable elements in the Add an app page. */ +enum class AddAnAppElement(override val impressionId: Int, override val interactionId: Int) : + ElementName { + POTENTIAL_PRIORITY_APP_BUTTON( + HEALTH_CONNECT_UI_IMPRESSION__ELEMENT__POTENTIAL_PRIORITY_APP_BUTTON, + HEALTH_CONNECT_UI_INTERACTION__ELEMENT__POTENTIAL_PRIORITY_APP_BUTTON) +} + /** Loggable elements belonging to the error page, and the unknown element. */ enum class ErrorPageElement(override val impressionId: Int, override val interactionId: Int) : ElementName { diff --git a/apk/tests/Android.bp b/apk/tests/Android.bp index 805fc047..ba4d2f71 100644 --- a/apk/tests/Android.bp +++ b/apk/tests/Android.bp @@ -78,9 +78,11 @@ android_test { "kotlinx_coroutines_test", // test dependencies "androidx.test.espresso.contrib", + "androidx.test.espresso.intents", "androidx.test.ext.junit", "androidx.test.ext.truth", "androidx.test.rules", + "mockito-kotlin2" ], resource_dirs: ["main_res"], libs: [ diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/MainActivityTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/MainActivityTest.kt index 2e5c4345..2124238b 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/MainActivityTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/MainActivityTest.kt @@ -64,7 +64,7 @@ class MainActivityTest { } @Test - fun homeSettingsIntent_onboardingNotDone_redirectToOnboarding() = runTest { + fun homeSettingsIntent_onboardingNotDone_redirectsToOnboarding() = runTest { showOnboarding(context, true) whenever(viewModel.getCurrentMigrationUiState()).then { MigrationState.COMPLETE_IDLE } whenever(viewModel.migrationState).then { @@ -83,7 +83,7 @@ class MainActivityTest { } @Test - fun homeSettingsIntent_migrationInProgress_redirectToMigrationScreen() = runTest { + fun homeSettingsIntent_migrationInProgress_redirectsToMigrationScreen() = runTest { showOnboarding(context, false) whenever(viewModel.getCurrentMigrationUiState()).then { MigrationState.IN_PROGRESS } whenever(viewModel.migrationState).then { diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/autodelete/api/LoadAutoDeleteUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/autodelete/api/LoadAutoDeleteUseCaseTest.kt new file mode 100644 index 00000000..355c1091 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/autodelete/api/LoadAutoDeleteUseCaseTest.kt @@ -0,0 +1,89 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * ``` + * http://www.apache.org/licenses/LICENSE-2.0 + * ``` + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.android.healthconnect.controller.tests.autodelete.api + +import android.health.connect.HealthConnectException +import android.health.connect.HealthConnectManager +import com.android.healthconnect.controller.autodelete.api.LoadAutoDeleteUseCase +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class LoadAutoDeleteUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + private val healthConnectManager: HealthConnectManager = + Mockito.mock(HealthConnectManager::class.java) + + private lateinit var loadAutoDeleteUseCase: LoadAutoDeleteUseCase + + @Before + fun setup() { + hiltRule.inject() + loadAutoDeleteUseCase = LoadAutoDeleteUseCase(healthConnectManager, Dispatchers.Main) + } + + @Test + fun loadAutoDeleteUseCase_whenRecordRetention90days_returns3months() = runTest { + whenever(healthConnectManager.recordRetentionPeriodInDays).thenReturn(90) + + val result = loadAutoDeleteUseCase.invoke() + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(3) + } + + @Test + fun loadAutoDeleteUseCase_whenRecordRetention540days_returns18months() = runTest { + whenever(healthConnectManager.recordRetentionPeriodInDays).thenReturn(540) + + val result = loadAutoDeleteUseCase.invoke() + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(18) + } + + @Test + fun loadAutoDeleteUseCase_whenRecordRetention0days_returns0months() = runTest { + whenever(healthConnectManager.recordRetentionPeriodInDays).thenReturn(0) + + val result = loadAutoDeleteUseCase.invoke() + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(0) + } + + @Test + fun loadAutoDeleteUseCase_whenRecordRetentionFails_returnsFailure() = runTest { + whenever(healthConnectManager.recordRetentionPeriodInDays) + .thenThrow(HealthConnectException(HealthConnectException.ERROR_UNKNOWN)) + + val result = loadAutoDeleteUseCase.invoke() + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception is HealthConnectException).isTrue() + assertThat((result.exception as HealthConnectException).errorCode) + .isEqualTo(HealthConnectException.ERROR_UNKNOWN) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/autodelete/api/UpdateAutoDeleteUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/autodelete/api/UpdateAutoDeleteUseCaseTest.kt new file mode 100644 index 00000000..203096b4 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/autodelete/api/UpdateAutoDeleteUseCaseTest.kt @@ -0,0 +1,126 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * ``` + * http://www.apache.org/licenses/LICENSE-2.0 + * ``` + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.android.healthconnect.controller.tests.autodelete.api + +import android.health.connect.HealthConnectException +import android.health.connect.HealthConnectManager +import android.os.OutcomeReceiver +import com.android.healthconnect.controller.autodelete.api.UpdateAutoDeleteUseCase +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.tests.utils.whenever +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class UpdateAutoDeleteUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + private val healthConnectManager: HealthConnectManager = + Mockito.mock(HealthConnectManager::class.java) + + private lateinit var updateAutoDeleteUseCase: UpdateAutoDeleteUseCase + + @Captor lateinit var captor: ArgumentCaptor<Int> + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + hiltRule.inject() + updateAutoDeleteUseCase = UpdateAutoDeleteUseCase(healthConnectManager, Dispatchers.Main) + } + + @Test + fun updateAutoDeleteUseCase_3months_callsManagerWithCorrectArgs() = runTest { + doAnswer(prepareAnswer()) + .`when`(healthConnectManager) + .setRecordRetentionPeriodInDays(any(), any(), any()) + + val result = updateAutoDeleteUseCase.invoke(3) + + verify(healthConnectManager, times(1)) + .setRecordRetentionPeriodInDays(captor.capture(), any(), any()) + assertThat(captor.value).isEqualTo(90) + assertThat(result is UseCaseResults.Success) + } + + @Test + fun updateAutoDeleteUseCase_18months_callsManagerWithCorrectArgs() = runTest { + doAnswer(prepareAnswer()) + .`when`(healthConnectManager) + .setRecordRetentionPeriodInDays(any(), any(), any()) + + val result = updateAutoDeleteUseCase.invoke(18) + + verify(healthConnectManager, times(1)) + .setRecordRetentionPeriodInDays(captor.capture(), any(), any()) + assertThat(captor.value).isEqualTo(540) + assertThat(result is UseCaseResults.Success) + } + + @Test + fun updateAutoDeleteUseCase_0months_callsManagerWithCorrectArgs() = runTest { + doAnswer(prepareAnswer()) + .`when`(healthConnectManager) + .setRecordRetentionPeriodInDays(any(), any(), any()) + + val result = updateAutoDeleteUseCase.invoke(0) + + verify(healthConnectManager, times(1)) + .setRecordRetentionPeriodInDays(captor.capture(), any(), any()) + assertThat(captor.value).isEqualTo(0) + assertThat(result is UseCaseResults.Success) + } + + @Test + fun updateAutoDeleteUseCase_whenSetRecordRetentionFails_returnsFailure() = runTest { + whenever(healthConnectManager.setRecordRetentionPeriodInDays(any(), any(), any())) + .thenThrow(HealthConnectException(HealthConnectException.ERROR_UNKNOWN)) + + val result = updateAutoDeleteUseCase.invoke(1) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception is HealthConnectException).isTrue() + Truth.assertThat((result.exception as HealthConnectException).errorCode) + .isEqualTo(HealthConnectException.ERROR_UNKNOWN) + } + + private fun prepareAnswer(): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = args.arguments[2] as OutcomeReceiver<*, *> + receiver.onResult(null) + null + } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/DataManagementActivityTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/DataManagementActivityTest.kt new file mode 100644 index 00000000..14ac4ad1 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/DataManagementActivityTest.kt @@ -0,0 +1,145 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * ``` + * http://www.apache.org/licenses/LICENSE-2.0 + * ``` + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.android.healthconnect.controller.tests.data + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.MutableLiveData +import androidx.test.core.app.ActivityScenario.launch +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.autodelete.AutoDeleteRange +import com.android.healthconnect.controller.autodelete.AutoDeleteViewModel +import com.android.healthconnect.controller.categories.HealthDataCategoryViewModel +import com.android.healthconnect.controller.data.DataManagementActivity +import com.android.healthconnect.controller.migration.MigrationViewModel +import com.android.healthconnect.controller.migration.api.MigrationState +import com.android.healthconnect.controller.tests.utils.di.FakeFeatureUtils +import com.android.healthconnect.controller.tests.utils.showOnboarding +import com.android.healthconnect.controller.tests.utils.whenever +import com.android.healthconnect.controller.utils.FeatureUtils +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito + +@HiltAndroidTest +@OptIn(ExperimentalCoroutinesApi::class) +class DataManagementActivityTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + + @BindValue + val migrationViewModel: MigrationViewModel = Mockito.mock(MigrationViewModel::class.java) + + @BindValue + val categoryViewModel: HealthDataCategoryViewModel = + Mockito.mock(HealthDataCategoryViewModel::class.java) + + @BindValue + val autoDeleteViewModel: AutoDeleteViewModel = Mockito.mock(AutoDeleteViewModel::class.java) + + @Inject lateinit var fakeFeatureUtils: FeatureUtils + + private lateinit var context: Context + + @Before + fun setup() { + hiltRule.inject() + context = InstrumentationRegistry.getInstrumentation().context + (fakeFeatureUtils as FakeFeatureUtils).setIsNewInformationArchitectureEnabled(false) + + showOnboarding(context, show = false) + whenever(autoDeleteViewModel.storedAutoDeleteRange).then { + MutableLiveData( + AutoDeleteViewModel.AutoDeleteState.WithData( + AutoDeleteRange.AUTO_DELETE_RANGE_NEVER)) + } + whenever(categoryViewModel.categoriesData).then { + MutableLiveData<HealthDataCategoryViewModel.CategoriesFragmentState>( + HealthDataCategoryViewModel.CategoriesFragmentState.WithData(emptyList())) + } + } + + @Test + fun manageDataIntent_onboardingDone_launchesDataManagementActivity() = runTest { + whenever(migrationViewModel.getCurrentMigrationUiState()).then { + MigrationState.COMPLETE_IDLE + } + whenever(migrationViewModel.migrationState).then { + MutableLiveData( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.COMPLETE_IDLE)) + } + + val startActivityIntent = Intent(context, DataManagementActivity::class.java) + + launch<DataManagementActivity>(startActivityIntent) + onView(withText("Browse data")).check(matches(isDisplayed())) + } + + @Test + fun manageDataIntent_onboardingNotDone_redirectsToOnboarding() = runTest { + showOnboarding(context, true) + whenever(migrationViewModel.getCurrentMigrationUiState()).then { + MigrationState.COMPLETE_IDLE + } + whenever(migrationViewModel.migrationState).then { + MutableLiveData( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.COMPLETE_IDLE)) + } + + val startActivityIntent = Intent(context, DataManagementActivity::class.java) + + launch<DataManagementActivity>(startActivityIntent) + + onView(withText("Share data with your apps")) + .perform(ViewActions.scrollTo()) + .check(matches(isDisplayed())) + } + + @Test + fun manageDataIntent_migrationInProgress_redirectsToMigrationScreen() = runTest { + showOnboarding(context, false) + whenever(migrationViewModel.getCurrentMigrationUiState()).then { + MigrationState.IN_PROGRESS + } + whenever(migrationViewModel.migrationState).then { + MutableLiveData( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.IN_PROGRESS)) + } + + val startActivityIntent = Intent(context, DataManagementActivity::class.java) + + launch<DataManagementActivity>(startActivityIntent) + + onView(withText("Integration in progress")).check(matches(isDisplayed())) + } + + @After + fun tearDown() { + showOnboarding(context, false) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/access/AccessViewModelTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/access/AccessViewModelTest.kt new file mode 100644 index 00000000..606e10cc --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/access/AccessViewModelTest.kt @@ -0,0 +1,74 @@ +package com.android.healthconnect.controller.tests.data.access + +import com.android.healthconnect.controller.data.access.AccessViewModel +import com.android.healthconnect.controller.data.access.AppAccessState +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.tests.utils.InstantTaskExecutorRule +import com.android.healthconnect.controller.tests.utils.TEST_APP +import com.android.healthconnect.controller.tests.utils.TEST_APP_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_3 +import com.android.healthconnect.controller.tests.utils.TestObserver +import com.android.healthconnect.controller.tests.utils.di.FakeLoadAccessUseCase +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.MockitoAnnotations + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class AccessViewModelTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: AccessViewModel + private val fakeLoadAccessUseCase = FakeLoadAccessUseCase() + private val testDispatcher = TestCoroutineDispatcher() + + @Inject lateinit var appInfoReader: AppInfoReader + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + Dispatchers.setMain(testDispatcher) + hiltRule.inject() + viewModel = AccessViewModel(fakeLoadAccessUseCase) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } + + @Test + fun loadAppMetadataMap_returnsCorrectApps() = runTest { + val expected = + mapOf( + AppAccessState.Read to listOf(TEST_APP, TEST_APP_2), + AppAccessState.Write to listOf(TEST_APP_2), + AppAccessState.Inactive to listOf(TEST_APP_3)) + fakeLoadAccessUseCase.updateMap(expected) + + val testObserver = TestObserver<AccessViewModel.AccessScreenState>() + viewModel.appMetadataMap.observeForever(testObserver) + viewModel.loadAppMetaDataMap(HealthPermissionType.STEPS) + advanceUntilIdle() + + assertThat(testObserver.getLastValue()) + .isEqualTo(AccessViewModel.AccessScreenState.WithData(expected)) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/access/LoadAccessUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/access/LoadAccessUseCaseTest.kt new file mode 100644 index 00000000..7772ea99 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/access/LoadAccessUseCaseTest.kt @@ -0,0 +1,90 @@ +package com.android.healthconnect.controller.tests.data.access + +import com.android.healthconnect.controller.data.access.AppAccessState +import com.android.healthconnect.controller.data.access.ILoadAccessUseCase +import com.android.healthconnect.controller.data.access.LoadAccessUseCase +import com.android.healthconnect.controller.permissions.data.HealthPermission +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.permissions.data.PermissionsAccessType +import com.android.healthconnect.controller.shared.HealthPermissionReader +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.tests.utils.TEST_APP +import com.android.healthconnect.controller.tests.utils.TEST_APP_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_NAME_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.di.FakeGetGrantedHealthPermissionsUseCase +import com.android.healthconnect.controller.tests.utils.di.FakeLoadPermissionTypeContributorAppsUseCase +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.MockitoAnnotations + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class LoadAccessUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + private lateinit var useCase: ILoadAccessUseCase + private val fakeLoadPermissionTypeContributorAppsUseCase = + FakeLoadPermissionTypeContributorAppsUseCase() + private val fakeFakeGetGrantedHealthPermissionsUseCase = + FakeGetGrantedHealthPermissionsUseCase() + + @Inject lateinit var appInfoReader: AppInfoReader + @Inject lateinit var healthPermissionReader: HealthPermissionReader + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + hiltRule.inject() + useCase = + LoadAccessUseCase( + fakeLoadPermissionTypeContributorAppsUseCase, + fakeFakeGetGrantedHealthPermissionsUseCase, + healthPermissionReader, + appInfoReader, + Dispatchers.Main) + } + + @Test + fun invoke_noDataNorPermission_returnsEmptyMap() = runTest { + val actual = (useCase.invoke(HealthPermissionType.STEPS) as UseCaseResults.Success).data + + assertThat(actual[AppAccessState.Write]!!.size).isEqualTo(0) + assertThat(actual[AppAccessState.Read]!!.size).isEqualTo(0) + assertThat(actual[AppAccessState.Inactive]!!.size).isEqualTo(0) + } + + @Test + fun invoke_returnsCorrectApps() = runTest { + fakeLoadPermissionTypeContributorAppsUseCase.updateList(listOf(TEST_APP, TEST_APP_2)) + val writeSteps = + HealthPermission(HealthPermissionType.STEPS, PermissionsAccessType.WRITE).toString() + fakeFakeGetGrantedHealthPermissionsUseCase.updateData( + TEST_APP_PACKAGE_NAME, listOf(writeSteps)) + + val actual = (useCase.invoke(HealthPermissionType.STEPS) as UseCaseResults.Success).data + + assertThat(actual[AppAccessState.Write]).isNotNull() + assertThat(actual[AppAccessState.Write]!!.size).isEqualTo(1) + assertThat(actual[AppAccessState.Write]!![0].packageName).isEqualTo(TEST_APP_PACKAGE_NAME) + assertThat(actual[AppAccessState.Write]!![0].appName).isEqualTo(TEST_APP_NAME) + assertThat(actual[AppAccessState.Read]).isNotNull() + assertThat(actual[AppAccessState.Read]!!.size).isEqualTo(0) + assertThat(actual[AppAccessState.Inactive]).isNotNull() + assertThat(actual[AppAccessState.Inactive]!!.size).isEqualTo(1) + assertThat(actual[AppAccessState.Inactive]!![0].packageName) + .isEqualTo(TEST_APP_PACKAGE_NAME_2) + assertThat(actual[AppAccessState.Inactive]!![0].appName).isEqualTo(TEST_APP_NAME_2) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/access/LoadPermissionTypeContributorAppsUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/access/LoadPermissionTypeContributorAppsUseCaseTest.kt new file mode 100644 index 00000000..1db30e55 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/access/LoadPermissionTypeContributorAppsUseCaseTest.kt @@ -0,0 +1,113 @@ +package com.android.healthconnect.controller.tests.data.access + +import android.content.Context +import android.health.connect.HealthConnectManager +import android.health.connect.HealthDataCategory +import android.health.connect.HealthPermissionCategory +import android.health.connect.RecordTypeInfoResponse +import android.health.connect.datatypes.HeartRateRecord +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.StepsRecord +import android.health.connect.datatypes.WeightRecord +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.data.access.LoadPermissionTypeContributorAppsUseCase +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.shared.app.AppMetadata +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_3 +import com.android.healthconnect.controller.tests.utils.getDataOrigin +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.invocation.InvocationOnMock + +@HiltAndroidTest +class LoadPermissionTypeContributorAppsUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + private lateinit var context: Context + private val healthConnectManager: HealthConnectManager = + Mockito.mock(HealthConnectManager::class.java) + private lateinit var loadPermissionTypeContributorAppsUseCase: + LoadPermissionTypeContributorAppsUseCase + + @Inject lateinit var appInfoReader: AppInfoReader + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + context = InstrumentationRegistry.getInstrumentation().context + hiltRule.inject() + loadPermissionTypeContributorAppsUseCase = + LoadPermissionTypeContributorAppsUseCase( + appInfoReader, healthConnectManager, Dispatchers.Main) + } + + @Test + fun loadPermissionTypeContributorAppsUseCase_noRecordsStored_returnsEmptyMap() = runTest { + Mockito.doAnswer(prepareAnswer(mapOf())) + .`when`(healthConnectManager) + .queryAllRecordTypesInfo(ArgumentMatchers.any(), ArgumentMatchers.any()) + + val result = loadPermissionTypeContributorAppsUseCase.invoke(HealthPermissionType.STEPS) + val expected = listOf<AppMetadata>() + assertThat(result).isEqualTo(expected) + } + + @Test + fun loadPermissionTypeContributorAppsUseCase_returnsCorrectApps() = runTest { + val recordTypeInfoMap: Map<Class<out Record>, RecordTypeInfoResponse> = + mapOf( + StepsRecord::class.java to + RecordTypeInfoResponse( + HealthPermissionCategory.STEPS, + HealthDataCategory.ACTIVITY, + listOf( + getDataOrigin(TEST_APP_PACKAGE_NAME), + getDataOrigin(TEST_APP_PACKAGE_NAME_2))), + WeightRecord::class.java to + RecordTypeInfoResponse( + HealthPermissionCategory.WEIGHT, + HealthDataCategory.BODY_MEASUREMENTS, + listOf((getDataOrigin(TEST_APP_PACKAGE_NAME_2)))), + HeartRateRecord::class.java to + RecordTypeInfoResponse( + HealthPermissionCategory.HEART_RATE, + HealthDataCategory.VITALS, + listOf((getDataOrigin(TEST_APP_PACKAGE_NAME_3))))) + Mockito.doAnswer(prepareAnswer(recordTypeInfoMap)) + .`when`(healthConnectManager) + .queryAllRecordTypesInfo(ArgumentMatchers.any(), ArgumentMatchers.any()) + + val result = loadPermissionTypeContributorAppsUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result.size).isEqualTo(2) + assertThat(result[0].packageName).isEqualTo(TEST_APP_PACKAGE_NAME) + assertThat(result[1].packageName).isEqualTo(TEST_APP_PACKAGE_NAME_2) + } + + private fun prepareAnswer( + map: Map<Class<out Record>, RecordTypeInfoResponse> + ): (InvocationOnMock) -> Map<Class<out Record>, RecordTypeInfoResponse> { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[1] + as OutcomeReceiver<Map<Class<out Record>, RecordTypeInfoResponse>, *> + receiver.onResult(map) + map + } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataAggregationsUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataAggregationsUseCaseTest.kt index e57d0194..06de631e 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataAggregationsUseCaseTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataAggregationsUseCaseTest.kt @@ -32,6 +32,7 @@ import java.time.Instant import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -41,6 +42,7 @@ import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.mockito.invocation.InvocationOnMock +@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest class LoadDataAggregationsUseCaseTest { diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataEntriesUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataEntriesUseCaseTest.kt new file mode 100644 index 00000000..46f6b8c7 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadDataEntriesUseCaseTest.kt @@ -0,0 +1,147 @@ +package com.android.healthconnect.controller.tests.data.entries.api + +import android.content.Context +import android.health.connect.HealthConnectException +import android.health.connect.HealthConnectManager +import android.health.connect.ReadRecordsRequestUsingFilters +import android.health.connect.ReadRecordsResponse +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.StepsCadenceRecord +import android.health.connect.datatypes.StepsRecord +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesInput +import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesUseCase +import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper +import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod +import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.tests.utils.forDataType +import com.android.healthconnect.controller.tests.utils.getStepsRecord +import com.android.healthconnect.controller.tests.utils.setLocale +import com.android.healthconnect.controller.utils.randomInstant +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.LocalDate +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.invocation.InvocationOnMock + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class LoadDataEntriesUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @Inject lateinit var healthDataEntryFormatter: HealthDataEntryFormatter + private val healthConnectManager: HealthConnectManager = + Mockito.mock(HealthConnectManager::class.java) + + private lateinit var context: Context + private lateinit var loadEntriesHelper: LoadEntriesHelper + private lateinit var loadDataEntriesUseCase: LoadDataEntriesUseCase + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + context = InstrumentationRegistry.getInstrumentation().context + context.setLocale(Locale.US) + hiltRule.inject() + loadEntriesHelper = + LoadEntriesHelper(context, healthDataEntryFormatter, healthConnectManager) + loadDataEntriesUseCase = LoadDataEntriesUseCase(Dispatchers.Main, loadEntriesHelper) + } + + @Test + fun invoke_returnsFormattedData() = runTest { + val stepsDate = LocalDate.of(2023, 4, 5) + val input = + LoadDataEntriesInput( + permissionType = HealthPermissionType.STEPS, + packageName = null, + displayedStartTime = stepsDate.toInstantAtStartOfDay(), + period = DateNavigationPeriod.PERIOD_DAY, + showDataOrigin = true) + + val stepsRecord = getStepsRecord(100, stepsDate.randomInstant()) + + Mockito.doAnswer(prepareRecordsAnswer(listOf(stepsRecord))) + .`when`(healthConnectManager) + .readRecords( + ArgumentMatchers.argThat<ReadRecordsRequestUsingFilters<Record>> { request -> + request.forDataType(dataType = StepsRecord::class.java) + }, + ArgumentMatchers.any(), + ArgumentMatchers.any()) + + Mockito.doAnswer(prepareRecordsAnswer(listOf())) + .`when`(healthConnectManager) + .readRecords( + ArgumentMatchers.argThat<ReadRecordsRequestUsingFilters<Record>> { request -> + request.forDataType(dataType = StepsCadenceRecord::class.java) + }, + ArgumentMatchers.any(), + ArgumentMatchers.any()) + + val expectedFormattedEntry = + healthDataEntryFormatter.format(stepsRecord, showDataOrigin = true) + val result = loadDataEntriesUseCase.invoke(input) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .containsExactlyElementsIn(listOf(expectedFormattedEntry)) + } + + @Test + fun invoke_whenLoadEntriesHelperUseCaseFails_returnsFailure() = runTest { + val sleepDate = LocalDate.of(2021, 9, 13) + + Mockito.doAnswer(prepareFailureAnswer()) + .`when`(healthConnectManager) + .readRecords<StepsRecord>( + ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()) + + val input = + LoadDataEntriesInput( + permissionType = HealthPermissionType.SLEEP, + packageName = null, + displayedStartTime = sleepDate.toInstantAtStartOfDay(), + period = DateNavigationPeriod.PERIOD_DAY, + showDataOrigin = true) + + val result = loadDataEntriesUseCase.invoke(input) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception is HealthConnectException).isTrue() + assertThat((result.exception as HealthConnectException).errorCode) + .isEqualTo(HealthConnectException.ERROR_UNKNOWN) + } + + private fun prepareRecordsAnswer(records: List<Record>): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = args.arguments[2] as OutcomeReceiver<ReadRecordsResponse<Record>, *> + receiver.onResult(ReadRecordsResponse(records, -1)) + null + } + return answer + } + + private fun prepareFailureAnswer(): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[2] as OutcomeReceiver<List<LocalDate>, HealthConnectException> + receiver.onError(HealthConnectException(HealthConnectException.ERROR_UNKNOWN)) + null + } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadSleepDataUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadEntriesHelperUseCaseTest.kt index 1cf0f107..8ced864c 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadSleepDataUseCaseTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/data/entries/api/LoadEntriesHelperUseCaseTest.kt @@ -1,3 +1,16 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ package com.android.healthconnect.controller.tests.data.entries.api import android.content.Context @@ -5,19 +18,17 @@ import android.health.connect.HealthConnectManager import android.health.connect.ReadRecordsRequestUsingFilters import android.health.connect.ReadRecordsResponse import android.health.connect.TimeInstantRangeFilter -import android.health.connect.datatypes.Record import android.health.connect.datatypes.SleepSessionRecord import android.os.OutcomeReceiver import androidx.test.platform.app.InstrumentationRegistry import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesInput import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper -import com.android.healthconnect.controller.data.entries.api.LoadSleepDataUseCase import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter import com.android.healthconnect.controller.permissions.data.HealthPermissionType -import com.android.healthconnect.controller.shared.usecase.UseCaseResults import com.android.healthconnect.controller.tests.utils.getMetaData import com.android.healthconnect.controller.tests.utils.setLocale +import com.android.healthconnect.controller.tests.utils.verifySleepSessionListsEqual import com.android.healthconnect.controller.utils.atStartOfDay import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule @@ -27,7 +38,6 @@ import java.time.ZoneId import java.util.Locale import java.util.TimeZone import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before @@ -37,23 +47,20 @@ import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers import org.mockito.Captor import org.mockito.Mockito -import org.mockito.Mockito.times -import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.mockito.invocation.InvocationOnMock @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest -class LoadSleepDataUseCaseTest { +class LoadEntriesHelperUseCaseTest { @get:Rule val hiltRule = HiltAndroidRule(this) private val healthConnectManager: HealthConnectManager = Mockito.mock(HealthConnectManager::class.java) - private lateinit var context: Context - @Inject lateinit var healthDataEntryFormatter: HealthDataEntryFormatter - private lateinit var loadSleepDataUseCase: LoadSleepDataUseCase + + private lateinit var context: Context private lateinit var loadEntriesHelper: LoadEntriesHelper @Captor @@ -67,65 +74,63 @@ class LoadSleepDataUseCaseTest { hiltRule.inject() loadEntriesHelper = LoadEntriesHelper(context, healthDataEntryFormatter, healthConnectManager) - loadSleepDataUseCase = LoadSleepDataUseCase(Dispatchers.Main, loadEntriesHelper) } - @Test - fun loadSleepDataUseCase_withinDay_returnsListOfRecords_sortedByDescendingStartTime() = - runTest { - TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("UTC"))) - - val startTime = Instant.parse("2023-06-12T22:30:00Z").atStartOfDay() - val input = - LoadDataEntriesInput( - displayedStartTime = startTime, - packageName = null, - period = DateNavigationPeriod.PERIOD_DAY, - showDataOrigin = true, - permissionType = HealthPermissionType.SLEEP) - - val expectedTimeRangeFilter = - loadEntriesHelper.getTimeFilter(startTime, DateNavigationPeriod.PERIOD_DAY, true) - - Mockito.doAnswer(prepareDaySleepAnswer()) - .`when`(healthConnectManager) - .readRecords( - ArgumentMatchers.any(ReadRecordsRequestUsingFilters::class.java), - ArgumentMatchers.any(), - ArgumentMatchers.any()) + // TODO (b/309288325) add tests for other permission types - val actual = loadSleepDataUseCase.invoke(input) - val expected = - listOf( - SleepSessionRecord.Builder( - getMetaData(), - Instant.parse("2023-06-12T22:30:00Z"), - Instant.parse("2023-06-13T07:45:00Z")) - .build(), - SleepSessionRecord.Builder( - getMetaData(), - Instant.parse("2023-06-12T21:00:00Z"), - Instant.parse("2023-06-12T21:20:00Z")) - .build(), - SleepSessionRecord.Builder( - getMetaData(), - Instant.parse("2023-06-12T16:00:00Z"), - Instant.parse("2023-06-12T17:45:00Z")) - .build(), - ) - - verify(healthConnectManager, times(1)) - .readRecords( - requestCaptor.capture(), ArgumentMatchers.any(), ArgumentMatchers.any()) - assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).startTime) - .isEqualTo(expectedTimeRangeFilter.startTime) - assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).endTime) - .isEqualTo(expectedTimeRangeFilter.endTime) - assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).isBounded) - .isEqualTo(expectedTimeRangeFilter.isBounded) - assertThat(actual is UseCaseResults.Success).isTrue() - verifySleepSessionListsEqual((actual as UseCaseResults.Success).data, expected) - } + @Test + fun loadSleepData_withinDay_returnsListOfRecords_sortedByDescendingStartTime() = runTest { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("UTC"))) + + val startTime = Instant.parse("2023-06-12T22:30:00Z").atStartOfDay() + val input = + LoadDataEntriesInput( + displayedStartTime = startTime, + packageName = null, + period = DateNavigationPeriod.PERIOD_DAY, + showDataOrigin = true, + permissionType = HealthPermissionType.SLEEP) + + val expectedTimeRangeFilter = + loadEntriesHelper.getTimeFilter(startTime, DateNavigationPeriod.PERIOD_DAY, true) + + Mockito.doAnswer(prepareDaySleepAnswer()) + .`when`(healthConnectManager) + .readRecords( + ArgumentMatchers.any(ReadRecordsRequestUsingFilters::class.java), + ArgumentMatchers.any(), + ArgumentMatchers.any()) + + val actual = loadEntriesHelper.readRecords(input) + val expected = + listOf( + SleepSessionRecord.Builder( + getMetaData(), + Instant.parse("2023-06-12T22:30:00Z"), + Instant.parse("2023-06-13T07:45:00Z")) + .build(), + SleepSessionRecord.Builder( + getMetaData(), + Instant.parse("2023-06-12T21:00:00Z"), + Instant.parse("2023-06-12T21:20:00Z")) + .build(), + SleepSessionRecord.Builder( + getMetaData(), + Instant.parse("2023-06-12T16:00:00Z"), + Instant.parse("2023-06-12T17:45:00Z")) + .build(), + ) + + Mockito.verify(healthConnectManager, Mockito.times(1)) + .readRecords(requestCaptor.capture(), ArgumentMatchers.any(), ArgumentMatchers.any()) + assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).startTime) + .isEqualTo(expectedTimeRangeFilter.startTime) + assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).endTime) + .isEqualTo(expectedTimeRangeFilter.endTime) + assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).isBounded) + .isEqualTo(expectedTimeRangeFilter.isBounded) + verifySleepSessionListsEqual(actual, expected) + } @Test fun loadSleepDataUseCase_withinWeek_returnsListOfRecords_sortedByDescendingStartTime() = @@ -151,7 +156,7 @@ class LoadSleepDataUseCaseTest { ArgumentMatchers.any(), ArgumentMatchers.any()) - val actual = loadSleepDataUseCase.invoke(input) + val actual = loadEntriesHelper.readRecords(input) val expected = listOf( SleepSessionRecord.Builder( @@ -180,7 +185,7 @@ class LoadSleepDataUseCaseTest { Instant.parse("2023-06-13T07:45:00Z")) .build()) - verify(healthConnectManager, times(1)) + Mockito.verify(healthConnectManager, Mockito.times(1)) .readRecords( requestCaptor.capture(), ArgumentMatchers.any(), ArgumentMatchers.any()) assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).startTime) @@ -189,8 +194,7 @@ class LoadSleepDataUseCaseTest { .isEqualTo(expectedTimeRangeFilter.endTime) assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).isBounded) .isEqualTo(expectedTimeRangeFilter.isBounded) - assertThat(actual is UseCaseResults.Success).isTrue() - verifySleepSessionListsEqual((actual as UseCaseResults.Success).data, expected) + verifySleepSessionListsEqual(actual, expected) } @Test @@ -217,7 +221,7 @@ class LoadSleepDataUseCaseTest { ArgumentMatchers.any(), ArgumentMatchers.any()) - val actual = loadSleepDataUseCase.invoke(input) + val actual = loadEntriesHelper.readRecords(input) val expected = listOf( SleepSessionRecord.Builder( @@ -251,7 +255,7 @@ class LoadSleepDataUseCaseTest { Instant.parse("2023-06-13T07:45:00Z")) .build()) - verify(healthConnectManager, times(1)) + Mockito.verify(healthConnectManager, Mockito.times(1)) .readRecords( requestCaptor.capture(), ArgumentMatchers.any(), ArgumentMatchers.any()) assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).startTime) @@ -260,26 +264,8 @@ class LoadSleepDataUseCaseTest { .isEqualTo(expectedTimeRangeFilter.endTime) assertThat((requestCaptor.value.timeRangeFilter as TimeInstantRangeFilter).isBounded) .isEqualTo(expectedTimeRangeFilter.isBounded) - assertThat(actual is UseCaseResults.Success).isTrue() - verifySleepSessionListsEqual((actual as UseCaseResults.Success).data, expected) - } - - private fun verifySleepSessionListsEqual( - actual: List<Record>, - expected: List<SleepSessionRecord> - ) { - assertThat(actual.size).isEqualTo(expected.size) - for ((index, element) in actual.withIndex()) { - val expectedElement = expected[index] - val actualElement = element as SleepSessionRecord - - assertThat(actualElement.startTime).isEqualTo(expectedElement.startTime) - assertThat(actualElement.endTime).isEqualTo(expectedElement.endTime) - assertThat(actualElement.notes).isEqualTo(expectedElement.notes) - assertThat(actualElement.title).isEqualTo(expectedElement.title) - assertThat(actualElement.stages).isEqualTo(expectedElement.stages) + verifySleepSessionListsEqual(actual, expected) } - } private fun prepareDaySleepAnswer(): (InvocationOnMock) -> ReadRecordsResponse<SleepSessionRecord> { diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/DataSourcesFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/DataSourcesFragmentTest.kt index 3b5f49ed..e1fb751d 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/DataSourcesFragmentTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/DataSourcesFragmentTest.kt @@ -16,7 +16,7 @@ package com.android.healthconnect.controller.tests.datasources import android.health.connect.HealthDataCategory -import android.os.Bundle +import androidx.core.os.bundleOf import androidx.lifecycle.MutableLiveData import androidx.test.espresso.Espresso.onIdle import androidx.test.espresso.Espresso.onView @@ -28,6 +28,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import com.android.healthconnect.controller.R +import com.android.healthconnect.controller.categories.HealthDataCategoriesFragment.Companion.CATEGORY_KEY import com.android.healthconnect.controller.data.entries.FormattedEntry import com.android.healthconnect.controller.datasources.AggregationCardInfo import com.android.healthconnect.controller.datasources.DataSourcesFragment @@ -50,6 +51,8 @@ import com.android.healthconnect.controller.tests.utils.di.FakeAppUtils import com.android.healthconnect.controller.tests.utils.launchFragment import com.android.healthconnect.controller.tests.utils.setLocale import com.android.healthconnect.controller.tests.utils.whenever +import com.android.healthconnect.controller.utils.logging.HealthConnectLogger +import com.android.healthconnect.controller.utils.logging.PageName import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -65,6 +68,10 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify @UninstallModules(AppUtilsModule::class) @HiltAndroidTest @@ -75,6 +82,7 @@ class DataSourcesFragmentTest { @BindValue val dataSourcesViewModel: DataSourcesViewModel = Mockito.mock(DataSourcesViewModel::class.java) @BindValue val appUtils: AppUtils = FakeAppUtils() + @BindValue val healthConnectLogger: HealthConnectLogger = mock<HealthConnectLogger>() @Before fun setup() { @@ -88,6 +96,7 @@ class DataSourcesFragmentTest { @After fun tearDown() { (appUtils as FakeAppUtils).reset() + reset(healthConnectLogger) } @Test @@ -104,7 +113,7 @@ class DataSourcesFragmentTest { whenever(dataSourcesViewModel.updatedAggregationCardsData).then { MutableLiveData(AggregationCardsState.WithData(true, listOf())) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onIdle() onView(withText("Activity")).check(matches(isDisplayed())) @@ -133,6 +142,9 @@ class DataSourcesFragmentTest { allOf( hasDescendant(withText("2")), hasDescendant(withText(TEST_APP_NAME_2)))))) + + verify(healthConnectLogger, atLeast(1)).setPageId(PageName.DATA_SOURCES_PAGE) + verify(healthConnectLogger, atLeast(1)).logPageImpression() } @Test @@ -167,7 +179,7 @@ class DataSourcesFragmentTest { "1234 steps", "1234 steps", "TestApp"), Instant.parse("2022-10-19T07:06:05.432Z"))))) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onView(withText("Activity")).check(matches(isDisplayed())) onView(withText("Data totals")).check(matches(isDisplayed())) @@ -231,7 +243,7 @@ class DataSourcesFragmentTest { "1234 steps", "1234 steps", "TestApp"), Instant.parse("2020-10-19T07:06:05.432Z"))))) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onView(withText("Data totals")).check(matches(isDisplayed())) onView(withText("1234 steps")).check(matches(isDisplayed())) onView(withText("October 19, 2020")).check(matches(isDisplayed())) @@ -272,7 +284,7 @@ class DataSourcesFragmentTest { Instant.parse("2022-10-19T08:05:00.00Z"))))) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.SLEEP)) onView(withText("Sleep")).check(matches(isDisplayed())) onView(withText("Data totals")).check(matches(isDisplayed())) @@ -339,7 +351,7 @@ class DataSourcesFragmentTest { Instant.parse("2020-10-19T08:05:00.00Z"))))) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.SLEEP)) onView(withText("Sleep")).check(matches(isDisplayed())) onView(withText("Data totals")).check(matches(isDisplayed())) @@ -406,7 +418,7 @@ class DataSourcesFragmentTest { Instant.parse("2021-01-01T08:05:00.00Z"))))) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.SLEEP)) onView(withText("Sleep")).check(matches(isDisplayed())) onView(withText("Data totals")).check(matches(isDisplayed())) @@ -450,7 +462,7 @@ class DataSourcesFragmentTest { whenever(dataSourcesViewModel.updatedAggregationCardsData).then { MutableLiveData(AggregationCardsState.WithData(true, listOf())) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onView(withText("Activity")).check(matches(isDisplayed())) onView(withText("No app sources")).check(matches(isDisplayed())) @@ -477,7 +489,7 @@ class DataSourcesFragmentTest { whenever(dataSourcesViewModel.updatedAggregationCardsData).then { MutableLiveData(AggregationCardsState.WithData(true, listOf())) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onIdle() onView(withText("Activity")).check(matches(isDisplayed())) @@ -523,7 +535,7 @@ class DataSourcesFragmentTest { MutableLiveData(AggregationCardsState.WithData(true, listOf())) } (appUtils as FakeAppUtils).setDefaultApp(TEST_APP_PACKAGE_NAME) - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onIdle() onView(withText("Activity")).check(matches(isDisplayed())) @@ -574,7 +586,7 @@ class DataSourcesFragmentTest { whenever(dataSourcesViewModel.updatedAggregationCardsData).then { MutableLiveData(AggregationCardsState.Loading(false)) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onView(withId(R.id.progress_indicator)).check(matches(isDisplayed())) } @@ -594,7 +606,7 @@ class DataSourcesFragmentTest { whenever(dataSourcesViewModel.updatedAggregationCardsData).then { MutableLiveData(AggregationCardsState.WithData(true, listOf())) } - launchFragment<DataSourcesFragment>(Bundle()) + launchFragment<DataSourcesFragment>(bundleOf(CATEGORY_KEY to HealthDataCategory.ACTIVITY)) onIdle() onView(withId(R.id.error_view)).check(matches(isDisplayed())) diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadLastDateWithPriorityDataUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadLastDateWithPriorityDataUseCaseTest.kt new file mode 100644 index 00000000..5395158d --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadLastDateWithPriorityDataUseCaseTest.kt @@ -0,0 +1,517 @@ +package com.android.healthconnect.controller.tests.datasources.api + +import android.content.Context +import android.health.connect.HealthConnectException +import android.health.connect.HealthConnectManager +import android.health.connect.ReadRecordsRequestUsingFilters +import android.health.connect.ReadRecordsResponse +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.StepsRecord +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper +import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod +import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter +import com.android.healthconnect.controller.datasources.api.LoadLastDateWithPriorityDataUseCase +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.tests.utils.CoroutineTestRule +import com.android.healthconnect.controller.tests.utils.TEST_APP +import com.android.healthconnect.controller.tests.utils.TEST_APP_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_3 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_3 +import com.android.healthconnect.controller.tests.utils.TestTimeSource +import com.android.healthconnect.controller.tests.utils.di.FakeLoadPriorityListUseCase +import com.android.healthconnect.controller.tests.utils.forDataType +import com.android.healthconnect.controller.tests.utils.fromDataSource +import com.android.healthconnect.controller.tests.utils.fromTimeRange +import com.android.healthconnect.controller.tests.utils.getRandomRecord +import com.android.healthconnect.controller.tests.utils.setLocale +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.any +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyZeroInteractions + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class LoadLastDateWithPriorityDataUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private lateinit var loadEntriesHelper: LoadEntriesHelper + private val loadPriorityListUseCase = FakeLoadPriorityListUseCase() + private val healthConnectManager = Mockito.mock(HealthConnectManager::class.java) + + private lateinit var loadLastDateWithPriorityDataUseCase: LoadLastDateWithPriorityDataUseCase + private lateinit var context: Context + private val timeSource = TestTimeSource + + @Inject lateinit var healthDataEntryFormatter: HealthDataEntryFormatter + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().context + context.setLocale(Locale.US) + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("UTC"))) + hiltRule.inject() + loadEntriesHelper = + LoadEntriesHelper(context, healthDataEntryFormatter, healthConnectManager) + loadLastDateWithPriorityDataUseCase = + LoadLastDateWithPriorityDataUseCase( + healthConnectManager, + loadEntriesHelper, + loadPriorityListUseCase, + timeSource, + Dispatchers.Main) + } + + @After + fun tearDown() { + loadPriorityListUseCase.reset() + timeSource.reset() + } + + @Test + fun emptyPriorityList_doesNotInvokeEntriesUseCase_returnsNull() = runTest { + loadPriorityListUseCase.updatePriorityList(listOf()) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isNull() + verifyZeroInteractions(healthConnectManager) + } + + @Test + fun onePriorityApp_noActivityDates_returnsNull() = runTest { + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP)) + + mockQueryActivityDatesAnswer(listOf()) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isNull() + Mockito.verify(healthConnectManager, times(0)).readRecords<StepsRecord>(any(), any(), any()) + } + + @Test + fun onePriorityApp_noData_returnsNull() = runTest { + val now = Instant.parse("2023-10-20T12:00:00Z") + timeSource.setNow(now) + + val dateWithData = LocalDate.of(2023, 10, 10) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP)) + mockQueryActivityDatesAnswer(listOf(dateWithData)) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = dateWithData, + numRecords = 0) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isNull() + } + + @Test + fun onePriorityApp_onlyDataOlderThan1Month_returnsNull() = runTest { + val now = Instant.parse("2023-11-01T12:00:00Z") + timeSource.setNow(now) + + val dateWithData = LocalDate.of(2023, 9, 10) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP)) + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = dateWithData, + numRecords = 2) + + mockQueryActivityDatesAnswer(listOf(dateWithData)) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isNull() + } + + @Test + fun multiplePriorityApps_withData_returnsMostRecentDateWithPriorityData() = runTest { + val now = Instant.parse("2023-11-07T12:00:00Z") + timeSource.setNow(now) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2, TEST_APP_3)) + + // datesWithin1MonthOfToday = 2023-11-1, 2023-11-2 + // min = 2023-11-1 + val activityDates = + listOf( + // Too old + LocalDate.of(2023, 10, 1), + // Too old + LocalDate.of(2023, 7, 11), + LocalDate.of(2023, 11, 1), + LocalDate.of(2023, 11, 2), + // Valid date but none of the priority apps have data then + LocalDate.of(2023, 11, 4), + // Future date with data, not included because we only + // query for data within the last 30 days + LocalDate.of(2024, 11, 2)) + + mockQueryActivityDatesAnswer(activityDates) + val minDateWithin1Month = LocalDate.of(2023, 11, 1) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = LocalDate.of(2023, 10, 1), + recordDates = listOf(LocalDate.of(2023, 10, 1))) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = minDateWithin1Month, + numRecords = 2) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = minDateWithin1Month, + recordDates = listOf(LocalDate.of(2023, 11, 1))) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = minDateWithin1Month, + recordDates = listOf(LocalDate.of(2023, 11, 1), LocalDate.of(2023, 11, 2))) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(LocalDate.of(2023, 11, 2)) + } + + @Test + fun multipleStepsPriorityApps_withDataAndWithout_returnsMostRecentDate() = runTest { + val now = Instant.parse("2023-11-07T12:00:00Z") + timeSource.setNow(now) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2, TEST_APP_3)) + + mockQueryActivityDatesAnswer( + listOf( + // Too old + LocalDate.of(2023, 10, 1), + // Too old + LocalDate.of(2023, 7, 11), + LocalDate.of(2023, 11, 1), + LocalDate.of(2023, 11, 2), + // In the future + LocalDate.of(2024, 11, 12))) + val minDateWithin1Month = LocalDate.of(2023, 11, 1) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = minDateWithin1Month, + numRecords = 0) + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = minDateWithin1Month, + numRecords = 1) + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = HealthPermissionType.STEPS, + queryDate = minDateWithin1Month, + recordDates = listOf(LocalDate.of(2023, 11, 1), LocalDate.of(2023, 11, 2))) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(LocalDate.of(2023, 11, 2)) + } + + @Test + fun multipleDistancePriorityApps_withDataAndWithout_returnsMostRecentDate() = runTest { + val now = Instant.parse("2023-10-14T12:00:00Z") + timeSource.setNow(now) + val healthPermissionType = HealthPermissionType.DISTANCE + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2, TEST_APP_3)) + + mockQueryActivityDatesAnswer( + listOf( + // Too old + LocalDate.of(2023, 8, 1), + // Too old + LocalDate.of(2023, 6, 11), + LocalDate.of(2023, 10, 1), + LocalDate.of(2023, 10, 2), + LocalDate.of(2023, 10, 12), + LocalDate.of(2023, 9, 26), + // Too old + LocalDate.of(2023, 3, 1), + // Too old + LocalDate.of(2021, 8, 12), + // Too old + LocalDate.of(2023, 7, 2))) + + val minDateWithin1Month = LocalDate.of(2023, 9, 26) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + numRecords = 0) + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + numRecords = 1) + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + recordDates = listOf(LocalDate.of(2023, 10, 12), minDateWithin1Month)) + + val result = loadLastDateWithPriorityDataUseCase.invoke(healthPermissionType) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(LocalDate.of(2023, 10, 12)) + } + + @Test + fun multipleCaloriesPriorityApps_withDataAndWithout_returnsMostRecentDate() = runTest { + val now = Instant.parse("2023-10-14T12:00:00Z") + timeSource.setNow(now) + val healthPermissionType = HealthPermissionType.TOTAL_CALORIES_BURNED + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2, TEST_APP_3)) + + mockQueryActivityDatesAnswer( + listOf( + LocalDate.of(2023, 10, 1), + // in the future + LocalDate.of(2023, 7, 11), + LocalDate.of(2023, 10, 12), + LocalDate.of(2023, 10, 14), + // Too old + LocalDate.of(2023, 4, 1), + // Too old + LocalDate.of(2021, 9, 13), + // Too old + LocalDate.of(2023, 8, 2))) + + val minDateWithin1Month = LocalDate.of(2023, 10, 1) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + numRecords = 1) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + recordDates = + listOf(LocalDate.of(2023, 10, 12), LocalDate.of(2023, 10, 14), minDateWithin1Month)) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + numRecords = 1) + + val result = loadLastDateWithPriorityDataUseCase.invoke(healthPermissionType) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(LocalDate.of(2023, 10, 14)) + } + + @Test + fun multipleSleepPriorityApps_withDataAndWithout_returnsMostRecentDate() = runTest { + val now = Instant.parse("2023-10-14T12:00:00Z") + timeSource.setNow(now) + val healthPermissionType = HealthPermissionType.SLEEP + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2, TEST_APP_3)) + + mockQueryActivityDatesAnswer( + listOf( + LocalDate.of(2023, 10, 1), + // in the future + LocalDate.of(2023, 7, 11), + LocalDate.of(2023, 10, 12), + LocalDate.of(2023, 10, 14), + // Too old + LocalDate.of(2023, 4, 1), + // Too old + LocalDate.of(2021, 9, 13), + // Too old + LocalDate.of(2023, 8, 2))) + + val minDateWithin1Month = LocalDate.of(2023, 10, 1) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + numRecords = 1) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + recordDates = + listOf(LocalDate.of(2023, 10, 12), LocalDate.of(2023, 10, 14), minDateWithin1Month)) + + mockReadRecordsResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = healthPermissionType, + queryDate = minDateWithin1Month, + numRecords = 1) + + val result = loadLastDateWithPriorityDataUseCase.invoke(healthPermissionType) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEqualTo(LocalDate.of(2023, 10, 14)) + } + + @Test + fun whenLoadPriorityListFails_returnsFailure() = runTest { + loadPriorityListUseCase.setFailure("Exception") + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + + verifyZeroInteractions(healthConnectManager) + Mockito.verify(healthConnectManager, times(0)).readRecords<StepsRecord>(any(), any(), any()) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") + } + + @Test + fun whenLoadEntriesHelperFails_returnsFailure() = runTest { + val now = Instant.parse("2023-10-14T12:00:00Z") + timeSource.setNow(now) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP)) + Mockito.doAnswer(prepareFailureAnswer()) + .`when`(healthConnectManager) + .readRecords<StepsRecord>(any(), any(), any()) + mockQueryActivityDatesAnswer(listOf(LocalDate.of(2023, 10, 5))) + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception is HealthConnectException).isTrue() + assertThat((result.exception as HealthConnectException).errorCode) + .isEqualTo(HealthConnectException.ERROR_UNKNOWN) + } + + @Test + fun whenQueryActivityDatesFails_returnsFailure() = runTest { + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP)) + mockQueryActivityDatesError() + + val result = loadLastDateWithPriorityDataUseCase.invoke(HealthPermissionType.STEPS) + + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception is HealthConnectException).isTrue() + assertThat((result.exception as HealthConnectException).errorCode) + .isEqualTo(HealthConnectException.ERROR_UNKNOWN) + } + + private fun mockReadRecordsResult( + packageName: String, + healthPermissionType: HealthPermissionType, + queryDate: LocalDate, + numRecords: Int + ) { + mockReadRecordsResult( + packageName, healthPermissionType, queryDate, List(numRecords) { queryDate }) + } + + private fun mockReadRecordsResult( + packageName: String, + healthPermissionType: HealthPermissionType, + queryDate: LocalDate, + recordDates: List<LocalDate> + ) { + val timeFilterRange = + loadEntriesHelper.getTimeFilter( + queryDate.toInstantAtStartOfDay(), + DateNavigationPeriod.PERIOD_MONTH, + endTimeExclusive = true) + val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(healthPermissionType) + val records = + recordDates.map { date -> getRandomRecord(healthPermissionType, date) }.toList() + + dataTypes.map { dataType -> + Mockito.doAnswer(prepareRecordsAnswer(records)) + .`when`(healthConnectManager) + .readRecords( + ArgumentMatchers.argThat<ReadRecordsRequestUsingFilters<Record>> { request -> + request.fromDataSource(packageName) && + request.fromTimeRange(timeFilterRange) && + request.forDataType(dataType) + }, + ArgumentMatchers.any(), + ArgumentMatchers.any()) + } + } + + private fun mockQueryActivityDatesAnswer(datesList: List<LocalDate>) { + Mockito.doAnswer(prepareActivityDatesAnswer(datesList)) + .`when`(healthConnectManager) + .queryActivityDates( + ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()) + } + + private fun mockQueryActivityDatesError() { + Mockito.doAnswer(prepareFailureAnswer()) + .`when`(healthConnectManager) + .queryActivityDates( + ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()) + } + + private fun prepareActivityDatesAnswer( + datesList: List<LocalDate> + ): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = args.arguments[2] as OutcomeReceiver<List<LocalDate>, *> + receiver.onResult(datesList) + null + } + return answer + } + + private fun prepareFailureAnswer(): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[2] as OutcomeReceiver<List<LocalDate>, HealthConnectException> + receiver.onError(HealthConnectException(HealthConnectException.ERROR_UNKNOWN)) + null + } + return answer + } + + private fun prepareRecordsAnswer(records: List<Record>): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = args.arguments[2] as OutcomeReceiver<ReadRecordsResponse<Record>, *> + receiver.onResult(ReadRecordsResponse(records, -1)) + null + } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadMostRecentAggregationsUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadMostRecentAggregationsUseCaseTest.kt index 3f6c58a5..6cf9625b 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadMostRecentAggregationsUseCaseTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadMostRecentAggregationsUseCaseTest.kt @@ -1,29 +1,23 @@ package com.android.healthconnect.controller.tests.datasources.api import android.content.Context -import android.health.connect.HealthConnectException -import android.health.connect.HealthConnectManager import android.health.connect.HealthDataCategory -import android.health.connect.datatypes.Record -import android.health.connect.datatypes.SleepSessionRecord -import android.os.OutcomeReceiver import androidx.test.platform.app.InstrumentationRegistry import com.android.healthconnect.controller.data.entries.FormattedEntry import com.android.healthconnect.controller.datasources.AggregationCardInfo import com.android.healthconnect.controller.datasources.api.LoadMostRecentAggregationsUseCase import com.android.healthconnect.controller.permissions.data.HealthPermissionType -import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper import com.android.healthconnect.controller.shared.usecase.UseCaseResults import com.android.healthconnect.controller.tests.utils.di.FakeLoadDataAggregationsUseCase -import com.android.healthconnect.controller.tests.utils.di.FakeLoadSleepDataUseCase -import com.android.healthconnect.controller.tests.utils.getMetaData +import com.android.healthconnect.controller.tests.utils.di.FakeLoadLastDateWithPriorityDataUseCase +import com.android.healthconnect.controller.tests.utils.di.FakeSleepSessionHelper import com.android.healthconnect.controller.tests.utils.setLocale -import com.android.healthconnect.controller.utils.atStartOfDay +import com.android.healthconnect.controller.utils.randomInstant +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay import com.android.healthconnect.controller.utils.toLocalDate import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.util.Locale @@ -35,11 +29,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.eq -import org.mockito.Mockito import org.mockito.MockitoAnnotations -import org.mockito.invocation.InvocationOnMock @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -58,80 +48,48 @@ class LoadMostRecentAggregationsUseCaseTest { private lateinit var context: Context private lateinit var loadMostRecentAggregationsUseCase: LoadMostRecentAggregationsUseCase - private val healthConnectManager: HealthConnectManager = - Mockito.mock(HealthConnectManager::class.java) private val loadDataAggregationsUseCase = FakeLoadDataAggregationsUseCase() - private val loadSleepDataUseCase = FakeLoadSleepDataUseCase() - - private val STEPS_DATE_1 = Instant.parse("2022-10-24T18:40:13.00Z") - private val STEPS_DATE_2 = Instant.parse("2022-10-26T13:23:19.00Z") - private val STEPS_DATE_3 = Instant.parse("2023-04-09T19:45:12.00Z") - - private val DISTANCE_DATE_1 = Instant.parse("2022-05-12T14:15:22.00Z") - private val DISTANCE_DATE_2 = Instant.parse("2022-11-03T07:20:18.00Z") - private val DISTANCE_DATE_3 = Instant.parse("2023-02-08T16:42:29.00Z") - - private val CALORIES_DATE_1 = Instant.parse("2022-07-26T11:33:10.00Z") - private val CALORIES_DATE_2 = Instant.parse("2022-09-30T12:55:44.00Z") - private val CALORIES_DATE_3 = Instant.parse("2023-04-19T20:25:37.00Z") - - private val SLEEP_DATE_1 = Instant.parse("2022-03-17T12:34:56.00Z") - private val SLEEP_DATE_2 = Instant.parse("2022-09-21T14:45:37.00Z") - private val SLEEP_DATE_3 = Instant.parse("2023-02-13T23:00:00.00Z") + private val loadLastDateWithPriorityDataUseCase = FakeLoadLastDateWithPriorityDataUseCase() + private val sleepSessionHelper = FakeSleepSessionHelper() private val stepsAggregation = formattedAggregation("100 steps") private val distanceAggregation = formattedAggregation("1.5 km") private val caloriesAggregation = formattedAggregation("1590 kcal") - private val sleepAggregation = formattedAggregation("11h 5m") - - private val stepsRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.STEPS) - private val distanceRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.DISTANCE) - private val caloriesRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.TOTAL_CALORIES_BURNED) - private val sleepRecordTypes = - HealthPermissionToDatatypeMapper.getDataTypes(HealthPermissionType.SLEEP) @Before fun setup() { MockitoAnnotations.initMocks(this) - context = InstrumentationRegistry.getInstrumentation().context - context.setLocale(Locale.US) hiltRule.inject() context = InstrumentationRegistry.getInstrumentation().context + context.setLocale(Locale.US) TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("UTC"))) loadMostRecentAggregationsUseCase = LoadMostRecentAggregationsUseCase( - healthConnectManager, loadDataAggregationsUseCase, - loadSleepDataUseCase, + loadLastDateWithPriorityDataUseCase, + sleepSessionHelper, Dispatchers.Main) } @After fun tearDown() { loadDataAggregationsUseCase.reset() - loadSleepDataUseCase.reset() + loadLastDateWithPriorityDataUseCase.reset() + sleepSessionHelper.reset() } @Test - fun loadMostRecentAggregations_forActivity_returnsMostRecent_stepsDistanceCalories() = runTest { - Mockito.doAnswer(prepareStepsAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareDistanceAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareCaloriesAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) + fun loadMostRecentAggregations_forActivity_returnsInOrder_stepsDistanceCalories() = runTest { + val stepsDate = LocalDate.of(2023, 4, 9) + val distanceDate = LocalDate.of(2023, 2, 8) + val caloriesDate = LocalDate.of(2023, 4, 19) + + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.STEPS, stepsDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.DISTANCE, distanceDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.TOTAL_CALORIES_BURNED, caloriesDate) loadDataAggregationsUseCase.updateAggregationResponses( listOf(stepsAggregation, distanceAggregation, caloriesAggregation)) @@ -142,803 +100,134 @@ class LoadMostRecentAggregationsUseCaseTest { .isEqualTo( listOf( AggregationCardInfo( - HealthPermissionType.STEPS, stepsAggregation, STEPS_DATE_3.atStartOfDay()), + HealthPermissionType.STEPS, + stepsAggregation, + stepsDate.toInstantAtStartOfDay()), AggregationCardInfo( HealthPermissionType.DISTANCE, distanceAggregation, - DISTANCE_DATE_3.atStartOfDay()), + distanceDate.toInstantAtStartOfDay()), AggregationCardInfo( HealthPermissionType.TOTAL_CALORIES_BURNED, caloriesAggregation, - CALORIES_DATE_3.atStartOfDay()), + caloriesDate.toInstantAtStartOfDay()), )) } - // Case 1 - start and end times on same day - @Test - fun loadMostRecentAggregations_forSleep_allSessionStartAndEndOnSameDay() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - - // 5h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") - - // 7h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) - - val expectedSleepAggregation = formattedAggregation("14h 5m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( - AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_1_START_DATE.atStartOfDay(), - SLEEP_SESSION_1_END_DATE.atStartOfDay()))) - } - - // Case 1 - start and end times on same day - // Edge case - additional sleep session starts on past date (not day before) - // And finishes on last day with data @Test - fun loadMostRecentAggregations_forSleep_allSessionStartAndEndOnSameDay_withSessionUnknownStart() = + fun loadMostRecentAggregations_forActivity_whenNoStepsData_returnsInOrder_DistanceCalories() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) + val stepsDate = null + val distanceDate = LocalDate.of(2023, 2, 8) + val caloriesDate = LocalDate.of(2023, 4, 19) - // lastDayWithSleepData = 2023-02-13 + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.STEPS, stepsDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.DISTANCE, distanceDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.TOTAL_CALORIES_BURNED, caloriesDate) - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + loadDataAggregationsUseCase.updateAggregationResponses( + listOf(distanceAggregation, caloriesAggregation)) - // 5h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") - - // 7h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - // Past sleep session ending on lastDayWithData, overlaps with above data by 1 hour - // 3d 7h 20m - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-10T01:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_4_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - val expectedSleepAggregation = formattedAggregation("15h 5m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) assertThat(result is UseCaseResults.Success).isTrue() assertThat((result as UseCaseResults.Success).data) .isEqualTo( listOf( AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_1_START_DATE.atStartOfDay(), - SLEEP_SESSION_1_END_DATE.atStartOfDay()))) - } - - // Case 1 - start and end times on same day - // Edge case - additional sleep session starts on past date (not day before) - // And finishes on future date - @Test - fun loadMostRecentAggregations_forSleep_allSessionStartAndEndOnSameDay_withSessionUnknownStartEnd() = - runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - - // 5h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") - - // 7h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - // Past sleep session, overlaps completely with above data - // 5d 7h 20m - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-10T01:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-15T08:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_4_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - // minStartTime = SLEEP_SESSION_3_START_DATE - // maxEndTime = SLEEP_SESSION_2_END_DATE - // Total time = 1am to 23:15 = 22h 15m - val expectedSleepAggregation = formattedAggregation("22h 15m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( - AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_1_START_DATE.atStartOfDay(), - SLEEP_SESSION_1_END_DATE.atStartOfDay()))) - } - - // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later - @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionStartsYesterdayAndEndsToday() = - runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - - // 5h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") - - // 9h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T23:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - // Should be partially included in aggregation - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") - - // Should not be included in aggregation - val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-12T12:00:00.00Z") - val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-12T14:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_3_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), - Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) - - // minStartTime = SLEEP_SESSION_3_START_DATE - // maxEndTime = SLEEP_SESSION_2_END_DATE - // Total time = 12 Feb, 23:00 - 13 Feb 23:15 = 24h 15m - val expectedSleepAggregation = formattedAggregation("24h 15m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( + HealthPermissionType.DISTANCE, + distanceAggregation, + distanceDate.toInstantAtStartOfDay()), AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_3_START_DATE.atStartOfDay(), - SLEEP_SESSION_1_END_DATE.atStartOfDay()))) + HealthPermissionType.TOTAL_CALORIES_BURNED, + caloriesAggregation, + caloriesDate.toInstantAtStartOfDay()), + )) } - // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later - // with gaps @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionStartsYesterdayAndEndsToday_withGaps() = + fun loadMostRecentAggregations_forActivity_whenNoDistanceData_returnsInOrder_StepsCalories() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) + val stepsDate = LocalDate.of(2023, 4, 9) + val distanceDate = null + val caloriesDate = LocalDate.of(2023, 4, 19) - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.STEPS, stepsDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.DISTANCE, distanceDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.TOTAL_CALORIES_BURNED, caloriesDate) - // lastDayWithSleepData = 2023-02-13 + loadDataAggregationsUseCase.updateAggregationResponses( + listOf(stepsAggregation, caloriesAggregation)) - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") - - // 2h 15m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T12:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T14:45:00.00Z") - - // 5h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T20:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T01:20:00.00Z") - - // Should be partially included in aggregation - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_3_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - // minStartTime = SLEEP_SESSION_3_START_DATE - // maxEndTime = SLEEP_SESSION_1_END_DATE - // Total time = 2h + 2h 15m + 5h 20m = 9h 35m - val expectedSleepAggregation = formattedAggregation("9h 35m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) assertThat(result is UseCaseResults.Success).isTrue() assertThat((result as UseCaseResults.Success).data) .isEqualTo( listOf( AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_3_START_DATE.atStartOfDay(), - SLEEP_SESSION_1_END_DATE.atStartOfDay()))) - } - - // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later - // Edge case - additional sleep session starts on past date - // and finishes on lastDayWithData - @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionStartsYesterdayAndEndsToday_withSessionUnknownStart() = - runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // secondToLastDayWithSleepData = 2023-02-12 - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - - // 5h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") - - // 10h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T22:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - // Should be partially included in aggregation - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") - - // Should be partially included in aggregation - val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-10T12:00:00.00Z") - val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-13T14:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_3_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_5_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) - - // minStartTime = SLEEP_SESSION_3_START_DATE - // maxEndTime = SLEEP_SESSION_2_END_DATE - // Total time = 12 Feb, 22:00 - 13 Feb 23:15 = 25h 15m - val expectedSleepAggregation = formattedAggregation("25h 15m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( + HealthPermissionType.STEPS, + stepsAggregation, + stepsDate.toInstantAtStartOfDay()), AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_3_START_DATE.atStartOfDay(), - SLEEP_SESSION_2_END_DATE.atStartOfDay()))) + HealthPermissionType.TOTAL_CALORIES_BURNED, + caloriesAggregation, + caloriesDate.toInstantAtStartOfDay()), + )) } - // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later - // Edge case - additional sleep session starts on Day 1 - // and finishes on unknown date @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionStartsYesterdayAndEndsToday_withSessionUnknownEnd() = + fun loadMostRecentAggregations_forActivity_whenNoCaloriesData_returnsInOrder_StepsDistance() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) + val stepsDate = LocalDate.of(2023, 4, 9) + val distanceDate = LocalDate.of(2023, 2, 8) + val caloriesDate = null - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.STEPS, stepsDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.DISTANCE, distanceDate) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.TOTAL_CALORIES_BURNED, caloriesDate) - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) + loadDataAggregationsUseCase.updateAggregationResponses( + listOf(stepsAggregation, distanceAggregation)) - // secondToLastDayWithSleepData = 2023-02-12 - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - - // 5h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") - - // 10h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T22:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") - - // Should be partially included in aggregation - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") - - // Should be partially included in aggregation up to 2023-02-14T00:00 - val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-12T12:00:00.00Z") - val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-18T14:20:00.00Z") - - val maxDate = Instant.parse("2023-02-14T00:00:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_3_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), - Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) - - // minStartTime = SLEEP_SESSION_5_START_DATE - // maxEndTime = 2023-02-14T00:00 - // Total time = 12 Feb, 12:00 - 14 Feb 00:00 = 36h - val expectedSleepAggregation = formattedAggregation("36h") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) assertThat(result is UseCaseResults.Success).isTrue() assertThat((result as UseCaseResults.Success).data) .isEqualTo( listOf( AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_5_START_DATE.atStartOfDay(), - maxDate.atStartOfDay()))) - } - - // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later - // Edge case - additional sleep session starts and finishes on unknown date - @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionStartsYesterdayAndEndsToday_withSessionUnknownStartAndEnd() = - runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // secondToLastDayWithSleepData = 2023-02-12 - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") - - // 2h 15m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T12:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T14:45:00.00Z") - - // 5h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T20:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T01:20:00.00Z") - - // Should be partially included in aggregation - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-10T16:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-20T23:20:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_3_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_4_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - // minStartTime = SLEEP_SESSION_3_START_DATE - // maxEndTime = SLEEP_SESSION_1_END_DATE - // Total time = 12 Oct 20:00 - 13 Oct 20:00 = 24h - val expectedSleepAggregation = formattedAggregation("24h") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( + HealthPermissionType.STEPS, + stepsAggregation, + stepsDate.toInstantAtStartOfDay()), AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_3_START_DATE.atStartOfDay(), - SLEEP_SESSION_1_END_DATE.atStartOfDay()))) + HealthPermissionType.DISTANCE, + distanceAggregation, + distanceDate.toInstantAtStartOfDay()))) } - // Case 3 - The sessions from lastDayWithData cross midnight into the next day @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionFinishesTomorrow() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") - - // 10h 15m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") - - // 2h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") - - // 10h - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_3_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - // minStartTime = SLEEP_SESSION_4_START_DATE - // maxEndTime = SLEEP_SESSION_2_END_DATE - // Total time = 13 Feb 22:00 - 14 Feb 08:45 = 10h 45m - val expectedSleepAggregation = formattedAggregation("10h 45m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) + fun loadMostRecentAggregations_ifNoActivityData_returnsEmptyList() = runTest { + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( - AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_4_START_DATE.atStartOfDay(), - SLEEP_SESSION_2_END_DATE.atStartOfDay()))) + assertThat((result as UseCaseResults.Success).data).isEmpty() } - // Case 3 - The sessions from lastDayWithData cross midnight into the next day - // Edge case - additional sleep session starts on unknown date - // and finishes within range @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionFinishesTomorrow_withSessionUnknownStart() = + fun loadMostRecentAggregations_forSleep_sessionsSpanOneDay_returnsAggregationInfoForOneDay() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") - - // 10h 15m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") - - // 2h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") - - // 10h - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") - - // 2d 11h - should not have an effect on the aggregation - val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-11T22:00:00.00Z") - val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-14T09:00:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_5_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) - - // minStartTime = SLEEP_SESSION_4_START_DATE - // maxEndTime = SLEEP_SESSION_2_END_DATE - // Total time = 13 Feb 22:00 - 14 Feb 08:45 = 10h 45m - val expectedSleepAggregation = formattedAggregation("10h 45m") - loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( - AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_4_START_DATE.atStartOfDay(), - SLEEP_SESSION_2_END_DATE.atStartOfDay()))) - } - - // Case 3 - The sessions from lastDayWithData cross midnight into the next day - // Edge case - additional sleep session starts on last day with data - // and finishes in the future - @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionFinishesTomorrow_withSessionUnknownEnd() = - runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") - - // 10h 15m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") - - // 2h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") - - // 10h - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") - - // 3d 11h - determines maxEndTime - val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") - val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-16T09:00:00.00Z") - - val maxEndTime = Instant.parse("2023-02-15T00:00:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), - Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) - - // minStartTime = SLEEP_SESSION_4_START_DATE - // maxEndTime = 15 Feb 00:00 - // Total time = 13 Feb 22:00 - 15 Feb 00:00 = 26h - val expectedSleepAggregation = formattedAggregation("26h") + val startDate = LocalDate.of(2023, 4, 5).randomInstant() + val endDate = LocalDate.of(2023, 4, 5).randomInstant() + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.SLEEP, startDate.toLocalDate()) + sleepSessionHelper.setDatePair(startDate, endDate) + val expectedSleepAggregation = formattedAggregation("14h 5m") loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) @@ -947,73 +236,24 @@ class LoadMostRecentAggregationsUseCaseTest { .isEqualTo( listOf( AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_4_START_DATE.atStartOfDay(), - maxEndTime.atStartOfDay()))) + healthPermissionType = HealthPermissionType.SLEEP, + aggregation = expectedSleepAggregation, + startDate = startDate, + endDate = endDate))) } - // Case 3 - The sessions from lastDayWithData cross midnight into the next day - // Edge case - additional sleep session starts and ends on unknown date @Test - fun loadMostRecentAggregations_forSleep_atLeastOneSessionFinishesTomorrow_withSessionUnknownStartAndEnd() = + fun loadMostRecentAggregations_forSleep_sessionsSpanTwoDays_returnsAggregationInfoWithStartAndEndTime() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // lastDayWithSleepData = 2023-02-13 - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") - - // 10h 15m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") + val startDate = LocalDate.of(2023, 4, 5).randomInstant() + val endDate = LocalDate.of(2023, 4, 7).randomInstant() - // 2h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.SLEEP, startDate.toLocalDate()) - // 10h - val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") - val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") + sleepSessionHelper.setDatePair(startDate, endDate) - // 5d 11h - Should not affect aggregation - val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-11T22:00:00.00Z") - val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-16T09:00:00.00Z") - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), - Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) - - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_5_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf(Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) - - // minStartTime = SLEEP_SESSION_4_START_DATE - // maxEndTime = SLEEP_SESSION_2_END_DATE - // Total time = 13 Feb 22:00 - 14 Feb 08:45 = 10h 45m - val expectedSleepAggregation = formattedAggregation("10h 45m") + val expectedSleepAggregation = formattedAggregation("36h 5m") loadDataAggregationsUseCase.updateAggregationResponses(listOf(expectedSleepAggregation)) val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) @@ -1022,215 +262,67 @@ class LoadMostRecentAggregationsUseCaseTest { .isEqualTo( listOf( AggregationCardInfo( - HealthPermissionType.SLEEP, - expectedSleepAggregation, - SLEEP_SESSION_4_START_DATE.atStartOfDay(), - SLEEP_SESSION_2_END_DATE.atStartOfDay()))) + healthPermissionType = HealthPermissionType.SLEEP, + aggregation = expectedSleepAggregation, + startDate = startDate, + endDate = endDate))) } @Test - fun loadMostRecentAggregations_forSleep_returnsMostRecent_sleepSessions() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareSleepAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - // 2h - val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") - val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") - - // 10h 45m - val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") - val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:15:00.00Z") - - // 9h 20m - val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T23:00:00.00Z") - val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-14T08:20:00.00Z") - - loadDataAggregationsUseCase.updateAggregationResponses(listOf(sleepAggregation)) - loadSleepDataUseCase.updateSleepData( - SLEEP_SESSION_1_START_DATE.toLocalDate(), - getSleepSessionRecords( - listOf( - Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), - Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), - Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) - + fun loadMostRecentAggregations_ifNoSleepData_returnsEmptyList() = runTest { val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data) - .isEqualTo( - listOf( - AggregationCardInfo( - HealthPermissionType.SLEEP, - sleepAggregation, - SLEEP_SESSION_1_START_DATE.atStartOfDay(), - SLEEP_SESSION_3_END_DATE.atStartOfDay()))) + assertThat((result as UseCaseResults.Success).data).isEmpty() } @Test - fun loadMostRecentAggregations_ifQueryActivityDatesFails_returnsFailure() = runTest { - Mockito.doAnswer(prepareStepsAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareFailedDistanceAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) + fun loadMostRecentAggregations_whenLoadLastDateWithPriorityDataFails_returnsFailure() = + runTest { + loadLastDateWithPriorityDataUseCase.setFailure("Exception") - Mockito.doAnswer(prepareCaloriesAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") + assertThat(loadDataAggregationsUseCase.invocationCount).isEqualTo(0) + } - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) + @Test + fun loadMostRecentAggregations_ifActivityAggregationRequestFails_returnsFailure() = runTest { + val stepsDate = LocalDate.of(2023, 2, 13) - loadDataAggregationsUseCase.updateAggregationResponses( - listOf(stepsAggregation, distanceAggregation, caloriesAggregation)) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.STEPS, stepsDate) + loadDataAggregationsUseCase.setFailure("Exception") val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") } @Test - fun loadMostRecentAggregations_ifAggregationRequestFails_returnsEmptyList() = runTest { - Mockito.doAnswer(prepareStepsAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) + fun loadMostRecentAggregations_ifSleepAggregationRequestFails_returnsFailure() = runTest { + val sleepDate = LocalDate.of(2023, 2, 13) - Mockito.doAnswer(prepareDistanceAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.SLEEP, sleepDate) + loadDataAggregationsUseCase.setFailure("Exception") - Mockito.doAnswer(prepareCaloriesAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - loadDataAggregationsUseCase.updateErrorResponse() - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data).isEmpty() + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") } @Test - fun loadMostRecentAggregations_ifNoData_returnsEmptyList() = runTest { - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(stepsRecordTypes), any(), any()) - - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(distanceRecordTypes), any(), any()) + fun loadMostRecentAggregations_ifSleepSessionHelperFails_returnsFailure() = runTest { + val sleepDate = LocalDate.of(2023, 2, 13) - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(caloriesRecordTypes), any(), any()) + loadLastDateWithPriorityDataUseCase.setLastDateWithPriorityDataForHealthPermissionType( + HealthPermissionType.SLEEP, sleepDate) + sleepSessionHelper.setFailure("Exception") - Mockito.doAnswer(prepareEmptyAnswer()) - .`when`(healthConnectManager) - .queryActivityDates(eq(sleepRecordTypes), any(), any()) - - val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.ACTIVITY) - assertThat(result is UseCaseResults.Success).isTrue() - assertThat((result as UseCaseResults.Success).data).isEmpty() - } - - private fun prepareStepsAnswer(): (InvocationOnMock) -> Nothing? { - val answer = { args: InvocationOnMock -> - val receiver = args.arguments[2] as OutcomeReceiver<List<LocalDate>, *> - receiver.onResult(getStepsDates()) - null - } - return answer - } - - private fun prepareDistanceAnswer(): (InvocationOnMock) -> Nothing? { - val answer = { args: InvocationOnMock -> - val receiver = args.arguments[2] as OutcomeReceiver<List<LocalDate>, *> - receiver.onResult(getDistanceDates()) - null - } - return answer - } - - private fun prepareFailedDistanceAnswer(): (InvocationOnMock) -> Nothing? { - val answer = { args: InvocationOnMock -> - val receiver = - args.arguments[2] as OutcomeReceiver<List<LocalDate>, HealthConnectException> - receiver.onError(HealthConnectException(HealthConnectException.ERROR_INTERNAL)) - null - } - return answer - } - - private fun prepareCaloriesAnswer(): (InvocationOnMock) -> Nothing? { - val answer = { args: InvocationOnMock -> - val receiver = args.arguments[2] as OutcomeReceiver<List<LocalDate>, *> - receiver.onResult(getCaloriesDates()) - null - } - return answer - } - - private fun prepareSleepAnswer(): (InvocationOnMock) -> Nothing? { - val answer = { args: InvocationOnMock -> - val receiver = args.arguments[2] as OutcomeReceiver<List<LocalDate>, *> - receiver.onResult(getSleepDates()) - null - } - return answer - } - - private fun prepareEmptyAnswer(): (InvocationOnMock) -> Nothing? { - val answer = { args: InvocationOnMock -> - val receiver = args.arguments[2] as OutcomeReceiver<List<LocalDate>, *> - receiver.onResult(listOf()) - null - } - return answer - } - - private fun getStepsDates(): List<LocalDate> = - listOf(STEPS_DATE_1.toLocalDate(), STEPS_DATE_2.toLocalDate(), STEPS_DATE_3.toLocalDate()) - - private fun getDistanceDates(): List<LocalDate> = - listOf( - DISTANCE_DATE_1.toLocalDate(), - DISTANCE_DATE_2.toLocalDate(), - DISTANCE_DATE_3.toLocalDate()) - - private fun getCaloriesDates(): List<LocalDate> = - listOf( - CALORIES_DATE_1.toLocalDate(), - CALORIES_DATE_2.toLocalDate(), - CALORIES_DATE_3.toLocalDate()) - - private fun getSleepDates(): List<LocalDate> = - listOf(SLEEP_DATE_1.toLocalDate(), SLEEP_DATE_2.toLocalDate(), SLEEP_DATE_3.toLocalDate()) - - private fun getSleepSessionRecords(inputDates: List<Pair<Instant, Instant>>): List<Record> { - val result = arrayListOf<Record>() - inputDates.forEach { (startTime, endTime) -> - result.add(SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build()) - } - - return result + val result = loadMostRecentAggregationsUseCase.invoke(HealthDataCategory.SLEEP) + assertThat(loadDataAggregationsUseCase.invocationCount).isEqualTo(0) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadPriorityEntriesUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadPriorityEntriesUseCaseTest.kt new file mode 100644 index 00000000..3a73968c --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/LoadPriorityEntriesUseCaseTest.kt @@ -0,0 +1,375 @@ +package com.android.healthconnect.controller.tests.datasources.api + +import android.content.Context +import android.health.connect.HealthConnectException +import android.health.connect.HealthConnectManager +import android.health.connect.ReadRecordsRequestUsingFilters +import android.health.connect.ReadRecordsResponse +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.SleepSessionRecord +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.data.entries.api.LoadEntriesHelper +import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod +import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter +import com.android.healthconnect.controller.datasources.api.LoadPriorityEntriesUseCase +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.tests.utils.TEST_APP +import com.android.healthconnect.controller.tests.utils.TEST_APP_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_3 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_3 +import com.android.healthconnect.controller.tests.utils.di.FakeLoadPriorityListUseCase +import com.android.healthconnect.controller.tests.utils.forDataType +import com.android.healthconnect.controller.tests.utils.fromDataSource +import com.android.healthconnect.controller.tests.utils.fromTimeRange +import com.android.healthconnect.controller.tests.utils.getSleepSessionRecords +import com.android.healthconnect.controller.tests.utils.setLocale +import com.android.healthconnect.controller.tests.utils.verifySleepSessionListsEqual +import com.android.healthconnect.controller.utils.toInstantAtStartOfDay +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.any +import org.mockito.kotlin.times + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class LoadPriorityEntriesUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + private lateinit var context: Context + + private val loadPriorityListUseCase = FakeLoadPriorityListUseCase() + private lateinit var loadEntriesHelper: LoadEntriesHelper + private val healthConnectManager = Mockito.mock(HealthConnectManager::class.java) + + private lateinit var loadPriorityEntriesUseCase: LoadPriorityEntriesUseCase + @Inject lateinit var healthDataEntryFormatter: HealthDataEntryFormatter + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + hiltRule.inject() + context = InstrumentationRegistry.getInstrumentation().context + context.setLocale(Locale.US) + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("UTC"))) + loadEntriesHelper = + LoadEntriesHelper(context, healthDataEntryFormatter, healthConnectManager) + loadPriorityEntriesUseCase = + LoadPriorityEntriesUseCase(loadEntriesHelper, loadPriorityListUseCase, Dispatchers.Main) + } + + @Test + fun invoke_onePriorityApp_doesNotIncludeNonPriorityData() = runTest { + val sleepDate = LocalDate.of(2023, 2, 13) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP)) + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 7h 20m - not on the priority list + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T23:20:00.00Z") + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) + + val result = loadPriorityEntriesUseCase.invoke(HealthPermissionType.SLEEP, sleepDate) + assertThat(result is UseCaseResults.Success).isTrue() + verifySleepSessionListsEqual( + actual = (result as UseCaseResults.Success).data, + expected = + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE)))) + } + + @Test + fun invoke_twoPriorityApps_doesNotIncludeNonPriorityData() = runTest { + val sleepDate = LocalDate.of(2023, 2, 13) + val pastSleepDate = LocalDate.of(2023, 2, 12) + + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2)) + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 7h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T23:20:00.00Z") + + // Should be partially included in aggregation + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") + + // Should not be included in aggregation + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-12T12:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-12T14:20:00.00Z") + + // Non priority session + val SLEEP_SESSION_6_START_DATE = Instant.parse("2023-02-13T00:10:00.00Z") + val SLEEP_SESSION_6_END_DATE = Instant.parse("2023-02-13T23:20:00.00Z") + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = pastSleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = pastSleepDate, + records = + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_6_START_DATE, SLEEP_SESSION_6_END_DATE)))) + + val result = loadPriorityEntriesUseCase.invoke(HealthPermissionType.SLEEP, sleepDate) + assertThat(result is UseCaseResults.Success).isTrue() + verifySleepSessionListsEqual( + actual = (result as UseCaseResults.Success).data, + expected = + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE)))) + } + + @Test + fun invoke_twoPriorityApps_noData_returnsEmptyList() = runTest { + // No priority sessions on this day + val noDataDate = LocalDate.of(2023, 2, 14) + val sleepDate = LocalDate.of(2023, 2, 13) + val pastSleepDate = LocalDate.of(2023, 2, 12) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP, TEST_APP_2)) + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 7h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T23:20:00.00Z") + + // Should be partially included in aggregation + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") + + // Should not be included in aggregation + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-12T12:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-12T14:20:00.00Z") + + // Non priority session + val SLEEP_SESSION_6_START_DATE = Instant.parse("2023-02-14T00:10:00.00Z") + val SLEEP_SESSION_6_END_DATE = Instant.parse("2023-02-14T23:20:00.00Z") + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = noDataDate, + records = listOf()) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = noDataDate, + records = listOf()) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = pastSleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_2, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = pastSleepDate, + records = + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + mockEntriesResult( + packageName = TEST_APP_PACKAGE_NAME_3, + healthPermissionType = HealthPermissionType.SLEEP, + queryDate = sleepDate, + records = + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_6_START_DATE, SLEEP_SESSION_6_END_DATE)))) + + val result = loadPriorityEntriesUseCase.invoke(HealthPermissionType.SLEEP, noDataDate) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isEmpty() + } + + @Test + fun invoke_whenPriorityFails_returnsFailure() = runTest { + val queryDate = LocalDate.of(2023, 1, 4) + loadPriorityListUseCase.setFailure("Exception") + + val result = loadPriorityEntriesUseCase.invoke(HealthPermissionType.SLEEP, queryDate) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") + Mockito.verify(healthConnectManager, times(0)) + .readRecords<SleepSessionRecord>(any(), any(), any()) + } + + @Test + fun invoke_whenLoadEntriesHelperFails_returnsFailure() = runTest { + val queryDate = LocalDate.of(2023, 1, 4) + loadPriorityListUseCase.updatePriorityList(listOf(TEST_APP_2, TEST_APP_3)) + Mockito.doAnswer(prepareFailureAnswer()) + .`when`(healthConnectManager) + .readRecords<SleepSessionRecord>(any(), any(), any()) + + val result = loadPriorityEntriesUseCase.invoke(HealthPermissionType.SLEEP, queryDate) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception is HealthConnectException).isTrue() + assertThat((result.exception as HealthConnectException).errorCode) + .isEqualTo(HealthConnectException.ERROR_UNKNOWN) + } + + private fun mockEntriesResult( + packageName: String, + healthPermissionType: HealthPermissionType, + queryDate: LocalDate, + records: List<Record> + ) { + val timeFilterRange = + loadEntriesHelper.getTimeFilter( + queryDate.toInstantAtStartOfDay(), + DateNavigationPeriod.PERIOD_DAY, + endTimeExclusive = true) + val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(healthPermissionType) + + dataTypes.map { dataType -> + Mockito.doAnswer(prepareRecordsAnswer(records)) + .`when`(healthConnectManager) + .readRecords( + ArgumentMatchers.argThat<ReadRecordsRequestUsingFilters<Record>> { request -> + request.fromDataSource(packageName) && + request.fromTimeRange(timeFilterRange) && + request.forDataType(dataType) + }, + ArgumentMatchers.any(), + ArgumentMatchers.any()) + } + } + + private fun prepareRecordsAnswer(records: List<Record>): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = args.arguments[2] as OutcomeReceiver<ReadRecordsResponse<Record>, *> + receiver.onResult(ReadRecordsResponse(records, -1)) + null + } + return answer + } + + private fun prepareFailureAnswer(): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[2] as OutcomeReceiver<List<LocalDate>, HealthConnectException> + receiver.onError(HealthConnectException(HealthConnectException.ERROR_UNKNOWN)) + null + } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/SleepSessionHelperTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/SleepSessionHelperTest.kt new file mode 100644 index 00000000..e7ba932c --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/datasources/api/SleepSessionHelperTest.kt @@ -0,0 +1,676 @@ +package com.android.healthconnect.controller.tests.datasources.api + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.datasources.api.SleepSessionHelper +import com.android.healthconnect.controller.shared.usecase.UseCaseResults +import com.android.healthconnect.controller.tests.utils.di.FakeLoadPriorityEntriesUseCase +import com.android.healthconnect.controller.tests.utils.getSleepSessionRecords +import com.android.healthconnect.controller.tests.utils.setLocale +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale +import java.util.TimeZone +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class SleepSessionHelperTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + private lateinit var context: Context + + private val loadPriorityEntriesUseCase = FakeLoadPriorityEntriesUseCase() + + private lateinit var sleepSessionHelper: SleepSessionHelper + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + hiltRule.inject() + context = InstrumentationRegistry.getInstrumentation().context + context.setLocale(Locale.US) + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("UTC"))) + sleepSessionHelper = SleepSessionHelper(loadPriorityEntriesUseCase, Dispatchers.Main) + } + + @After + fun tearDown() { + loadPriorityEntriesUseCase.reset() + } + + // Case 1 - start and end times on same day + @Test + fun clusterSessions_allSessionsStartAndEndOnSameDay_returnsMinAndMaxOfAllSessions() = runTest { + val sleepDate = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 7h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + sleepDate, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) + + val result = sleepSessionHelper.clusterSleepSessions(sleepDate) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 1 - start and end times on same day + // Edge case - additional sleep session starts on past date (not day before) + // And finishes on last day with data + @Test + fun clusterSessions_allSessionStartAndEndOnSameDay_withSessionUnknownStart_doesNotIncludeUnknownSessionInMinAndMax() = + runTest { + val sleepDate = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 7h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + // Past sleep session ending on lastDayWithData + // 3d 7h 20m + val pastSleepDate = LocalDate.of(2023, 2, 10) + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-10T01:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + sleepDate, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + ))) + + loadPriorityEntriesUseCase.setEntriesList( + pastSleepDate, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + val result = sleepSessionHelper.clusterSleepSessions(sleepDate) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 1 - start and end times on same day + // Edge case - additional sleep session starts on past date (not day before) + // And finishes on future date + @Test + fun clusterSessions_allSessionStartAndEndOnSameDay_withSessionUnknownStartEnd_doesNotIncludeUnknownSessionInMinAndMax() = + runTest { + val sleepDate = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 7h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T01:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + // Past sleep session, overlaps completely with above data + // 5d 7h 20m + val pastSleepSessionStartDate = LocalDate.of(2023, 2, 10) + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-10T01:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-15T08:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + sleepDate, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + ))) + + loadPriorityEntriesUseCase.setEntriesList( + pastSleepSessionStartDate, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + val result = sleepSessionHelper.clusterSleepSessions(sleepDate) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later + @Test + fun clusterSessions_atLeastOneSessionStartsYesterdayAndEndsToday_includesCrossingSessionInMinAndMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + val secondToLastDateWithData = LocalDate.of(2023, 2, 12) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 9h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T23:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + // Should be partially included in aggregation + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") + + // Should not be included in aggregation + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-12T12:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-12T14:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + secondToLastDateWithData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), + Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + // minStartTime = SLEEP_SESSION_3_START_DATE + // maxEndTime = SLEEP_SESSION_2_END_DATE + // Total time = 12 Feb, 23:00 - 13 Feb 23:15 = 24h 15m + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later + // with gaps + @Test + fun clusterSessions_atLeastOneSessionStartsYesterdayAndEndsToday_withGaps_returnsCorrectMinAndMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + val secondToLastDateWithSleepData = LocalDate.of(2023, 2, 12) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") + + // 2h 15m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T12:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T14:45:00.00Z") + + // 5h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T20:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T01:20:00.00Z") + + // Should be partially included in aggregation + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + secondToLastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + // minStartTime = SLEEP_SESSION_3_START_DATE + // maxEndTime = SLEEP_SESSION_1_END_DATE + // Total time = 2h + 2h 15m + 5h 20m = 9h 35m + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_1_END_DATE)) + } + + // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later + // Edge case - additional sleep session starts on past date + // and finishes on lastDayWithData + @Test + fun clusterSessions_atLeastOneSessionStartsYesterdayAndEndsToday_withSessionUnknownStart_doesNotIncludeUnknownSessionInMinAndMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + val secondToLastDateWithSleepData = LocalDate.of(2023, 2, 12) + + // secondToLastDayWithSleepData = 2023-02-12 + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 10h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T22:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + // Should not be included in calculation + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") + + // Should not be included in calculation + val pastDateWithSleepData = LocalDate.of(2023, 2, 10) + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-10T12:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-13T14:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + secondToLastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), + ))) + + loadPriorityEntriesUseCase.setEntriesList( + pastDateWithSleepData, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + // minStartTime = SLEEP_SESSION_3_START_DATE + // maxEndTime = SLEEP_SESSION_2_END_DATE + // Total time = 2h + 2h 15m + 5h 20m = 9h 35m + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later + // Edge case - additional sleep session starts on Day 1 + // and finishes on unknown date + // Then the maxEndDate should be forced at Day 3 midnight + @Test + fun clusterSessions_atLeastOneSessionStartsYesterdayAndEndsToday_withSessionUnknownEnd_returnsForcedMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + val secondToLastDateWithSleepData = LocalDate.of(2023, 2, 12) + + // secondToLastDayWithSleepData = 2023-02-12 + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + + // 5h 45m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T17:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T23:15:00.00Z") + + // 10h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T22:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T08:20:00.00Z") + + // Should be partially included in aggregation + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-12T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-12T23:20:00.00Z") + + // Should be partially included in aggregation up to 2023-02-14T00:00 + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-12T12:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-18T14:20:00.00Z") + + val maxDate = Instant.parse("2023-02-14T00:00:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + secondToLastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), + Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + // minStartTime = SLEEP_SESSION_5_START_DATE + // maxEndTime = 2023-02-14T00:00 + // Total time = 12 Feb, 12:00 - 14 Feb 00:00 = 36h + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_5_START_DATE, maxDate)) + } + + // Case 2 - At least one session starts on Day 1 and finishes on Day 2 or later + // Edge case - additional sleep session starts and finishes on unknown date + @Test + fun clusterSessions_atLeastOneSessionStartsYesterdayAndEndsToday_withSessionUnknownStartAndEnd_doesNotIncludeUnknownSessionInMinAndMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + val secondToLastDateWithSleepData = LocalDate.of(2023, 2, 12) + + // secondToLastDayWithSleepData = 2023-02-12 + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") + + // 2h 15m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T12:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-13T14:45:00.00Z") + + // 5h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-12T20:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T01:20:00.00Z") + + // Should be partially included in aggregation + val pastDateWithSleepData = LocalDate.of(2023, 2, 10) + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-10T16:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-20T23:20:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + secondToLastDateWithSleepData, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + pastDateWithSleepData, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + // minStartTime = SLEEP_SESSION_3_START_DATE + // maxEndTime = SLEEP_SESSION_1_END_DATE + // Total time = 12 Oct 20:00 - 13 Oct 20:00 = 24h + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_1_END_DATE)) + } + + // Case 3 - The sessions from lastDayWithData cross midnight into the next day + @Test + fun clusterSessions_atLeastOneSessionFinishesTomorrow_returnsMaxFromTomorrow() = runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") + + // 10h 15m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") + + // 2h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") + + // 10h + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + // minStartTime = SLEEP_SESSION_4_START_DATE + // maxEndTime = SLEEP_SESSION_2_END_DATE + // Total time = 13 Feb 22:00 - 14 Feb 08:45 = 10h 45m + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 3 - The sessions from lastDayWithData cross midnight into the next day + // Edge case - additional sleep session starts on unknown date + // and finishes within range + @Test + fun clusterSessions_atLeastOneSessionFinishesTomorrow_withSessionUnknownStart_doesNotIncludeUnknownSessionInMinAndMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") + + // 10h 15m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") + + // 2h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") + + // 10h + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") + + // 2d 11h - should not have an effect on the aggregation + val pastDateWithSleepData = LocalDate.of(2023, 2, 11) + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-11T22:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-14T09:00:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + pastDateWithSleepData, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + // minStartTime = SLEEP_SESSION_4_START_DATE + // maxEndTime = SLEEP_SESSION_2_END_DATE + // Total time = 13 Feb 22:00 - 14 Feb 08:45 = 10h 45m + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + // Case 3 - The sessions from lastDayWithData cross midnight into the next day + // Edge case - additional sleep session starts on last day with data + // and finishes in the future + @Test + fun clusterSessions_atLeastOneSessionFinishesTomorrow_withSessionUnknownEnd_returnsForcedMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") + + // 10h 15m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") + + // 2h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") + + // 10h + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") + + // 3d 11h - determines maxEndTime + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-16T09:00:00.00Z") + + val maxEndTime = Instant.parse("2023-02-15T00:00:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE), + Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + // minStartTime = SLEEP_SESSION_4_START_DATE + // maxEndTime = 15 Feb 00:00 + // Total time = 13 Feb 22:00 - 15 Feb 00:00 = 26h + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_4_START_DATE, maxEndTime)) + } + + // Case 3 - The sessions from lastDayWithData cross midnight into the next day + // Edge case - additional sleep session starts and ends on unknown date + @Test + fun clusterSessions_atLeastOneSessionFinishesTomorrow_withSessionUnknownStartAndEnd_doesNotIncludeUnknownSessionInMinAndMax() = + runTest { + val lastDateWithSleepData = LocalDate.of(2023, 2, 13) + + // lastDayWithSleepData = 2023-02-13 + + // 2h + val SLEEP_SESSION_1_START_DATE = Instant.parse("2023-02-13T18:00:00.00Z") + val SLEEP_SESSION_1_END_DATE = Instant.parse("2023-02-13T20:00:00.00Z") + + // 10h 15m + val SLEEP_SESSION_2_START_DATE = Instant.parse("2023-02-13T22:30:00.00Z") + val SLEEP_SESSION_2_END_DATE = Instant.parse("2023-02-14T08:45:00.00Z") + + // 2h 20m + val SLEEP_SESSION_3_START_DATE = Instant.parse("2023-02-13T16:00:00.00Z") + val SLEEP_SESSION_3_END_DATE = Instant.parse("2023-02-13T18:20:00.00Z") + + // 10h + val SLEEP_SESSION_4_START_DATE = Instant.parse("2023-02-13T22:00:00.00Z") + val SLEEP_SESSION_4_END_DATE = Instant.parse("2023-02-14T08:00:00.00Z") + + // 5d 11h - Should not affect aggregation + val pastDateWithSleepData = LocalDate.of(2023, 2, 11) + val SLEEP_SESSION_5_START_DATE = Instant.parse("2023-02-11T22:00:00.00Z") + val SLEEP_SESSION_5_END_DATE = Instant.parse("2023-02-16T09:00:00.00Z") + + loadPriorityEntriesUseCase.setEntriesList( + lastDateWithSleepData, + getSleepSessionRecords( + listOf( + Pair(SLEEP_SESSION_1_START_DATE, SLEEP_SESSION_1_END_DATE), + Pair(SLEEP_SESSION_2_START_DATE, SLEEP_SESSION_2_END_DATE), + Pair(SLEEP_SESSION_3_START_DATE, SLEEP_SESSION_3_END_DATE), + Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_4_END_DATE)))) + + loadPriorityEntriesUseCase.setEntriesList( + pastDateWithSleepData, + getSleepSessionRecords( + listOf(Pair(SLEEP_SESSION_5_START_DATE, SLEEP_SESSION_5_END_DATE)))) + + // minStartTime = SLEEP_SESSION_4_START_DATE + // maxEndTime = SLEEP_SESSION_2_END_DATE + // Total time = 13 Feb 22:00 - 14 Feb 08:45 = 10h 45m + val result = sleepSessionHelper.clusterSleepSessions(lastDateWithSleepData) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data) + .isEqualTo(Pair(SLEEP_SESSION_4_START_DATE, SLEEP_SESSION_2_END_DATE)) + } + + @Test + fun clusterSessions_whenLoadPriorityEntriesFails_returnsFailure() = runTest { + val queryDate = LocalDate.of(2023, 1, 30) + loadPriorityEntriesUseCase.setFailure("Exception") + + val result = sleepSessionHelper.clusterSleepSessions(queryDate) + assertThat(result is UseCaseResults.Failed).isTrue() + assertThat((result as UseCaseResults.Failed).exception.message).isEqualTo("Exception") + } + + @Test + fun clusterSessions_whenNoData_returnsNull() = runTest { + val queryDate = LocalDate.of(2023, 1, 30) + + val result = sleepSessionHelper.clusterSleepSessions(queryDate) + assertThat(result is UseCaseResults.Success).isTrue() + assertThat((result as UseCaseResults.Success).data).isNull() + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionFragmentTest.kt index ef3c558c..29163821 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionFragmentTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionFragmentTest.kt @@ -31,8 +31,10 @@ import com.android.healthconnect.controller.R import com.android.healthconnect.controller.deletion.ChosenRange import com.android.healthconnect.controller.deletion.DeletionConstants.DELETION_TYPE import com.android.healthconnect.controller.deletion.DeletionConstants.START_DELETION_EVENT +import com.android.healthconnect.controller.deletion.DeletionConstants.START_INACTIVE_APP_DELETION_EVENT import com.android.healthconnect.controller.deletion.DeletionFragment import com.android.healthconnect.controller.deletion.DeletionParameters +import com.android.healthconnect.controller.deletion.DeletionState import com.android.healthconnect.controller.deletion.DeletionType import com.android.healthconnect.controller.deletion.DeletionViewModel import com.android.healthconnect.controller.permissions.data.HealthPermissionType @@ -213,6 +215,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete all data from the last 24 hours?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -240,6 +248,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete all data from the last 7 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -267,6 +281,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete all data from the last 30 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -294,6 +314,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete all data from all time?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -322,6 +348,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete activity data from the last 24 hours?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -349,6 +381,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete activity data from the last 7 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -377,6 +415,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete activity data from the last 30 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -404,6 +448,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete activity data from all time?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -434,6 +484,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete blood glucose data from the last 24 hours?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -463,6 +519,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete blood glucose data from the last 7 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -493,6 +555,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete blood glucose data from the last 30 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -522,6 +590,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete blood glucose data from all time?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -551,6 +625,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete $TEST_APP_NAME data from the last 24 hours?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -579,6 +659,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete $TEST_APP_NAME data from the last 7 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -608,6 +694,12 @@ class DeletionFragmentTest { onView(withText("Permanently delete $TEST_APP_NAME data from the last 30 days?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -636,6 +728,43 @@ class DeletionFragmentTest { onView(withText("Permanently delete $TEST_APP_NAME data from all time?")) .inRoot(isDialog()) .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + + @Test + fun deleteInActiveAppData_confirmationDialog_showsCorrectText() { + val deletionTypeAppData = + DeletionType.DeletionTypeAppData( + packageName = TEST_APP_PACKAGE_NAME, appName = TEST_APP_NAME) + Mockito.`when`(viewModel.deletionParameters).then { + MutableLiveData( + DeletionParameters( + deletionType = deletionTypeAppData, + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA)) + } + + launchFragment<DeletionFragment>(Bundle()) { + (this as DeletionFragment) + .parentFragmentManager + .setFragmentResult( + START_INACTIVE_APP_DELETION_EVENT, + bundleOf(DELETION_TYPE to deletionTypeAppData)) + } + + onView(withText("Permanently delete $TEST_APP_NAME data from all time?")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -666,9 +795,27 @@ class DeletionFragmentTest { .inRoot(isDialog()) .check(matches(isDisplayed())) + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText("Go back")).inRoot(isDialog()).perform(click()) onView(withText("Choose data to delete")).inRoot(isDialog()).check(matches(isDisplayed())) + onView( + withText( + "This permanently deletes all data added to Health\u00A0Connect in the chosen" + + " time period")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText("Delete last 24 hours")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Delete last 7 days")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Delete last 30 days")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Delete all data")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Cancel")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Next")).inRoot(isDialog()).check(matches(isDisplayed())) } @Test @@ -693,7 +840,13 @@ class DeletionFragmentTest { onView( withText( - "Connected apps will no longer be able to access this data from Health Connect")) + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) .inRoot(isDialog()) .check(matches(isDisplayed())) @@ -729,8 +882,174 @@ class DeletionFragmentTest { .inRoot(isDialog()) .check(matches(isDisplayed())) + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText("Cancel")).inRoot(isDialog()).perform(click()) onView(withText("Permanently delete all data from all time?")).check(doesNotExist()) } + + @Test + fun deleteFragment_progressIndicatorStartedState_progressIndicatorShown() { + val deletionTypeAllData = DeletionType.DeletionTypeAllData() + + Mockito.`when`(viewModel.deletionParameters).then { + MutableLiveData( + DeletionParameters( + deletionState = DeletionState.STATE_PROGRESS_INDICATOR_STARTED, + deletionType = deletionTypeAllData, + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA)) + } + + launchFragment<DeletionFragment>(Bundle()) { + (this as DeletionFragment) + .parentFragmentManager + .setFragmentResult( + START_DELETION_EVENT, bundleOf(DELETION_TYPE to deletionTypeAllData)) + } + + onView(withId(R.id.radio_button_all)).inRoot(isDialog()).perform(click()) + + onView(withText("Next")).inRoot(isDialog()).perform(click()) + + onView(withText("Permanently delete all data from all time?")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView(withText("Delete")).inRoot(isDialog()).perform(click()) + + onView(withText("Deleting your data")).inRoot(isDialog()).check(matches(isDisplayed())) + } + + @Test + fun deleteFragment_progressIndicatorCanEndState_progressIndicatorDisappears() { + val deletionTypeAllData = DeletionType.DeletionTypeAllData() + + Mockito.`when`(viewModel.deletionParameters).then { + MutableLiveData( + DeletionParameters( + deletionState = DeletionState.STATE_PROGRESS_INDICATOR_CAN_END, + deletionType = deletionTypeAllData, + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA)) + } + + launchFragment<DeletionFragment>(Bundle()) { + (this as DeletionFragment) + .parentFragmentManager + .setFragmentResult( + START_DELETION_EVENT, bundleOf(DELETION_TYPE to deletionTypeAllData)) + } + + onView(withId(R.id.radio_button_all)).inRoot(isDialog()).perform(click()) + + onView(withText("Next")).inRoot(isDialog()).perform(click()) + + onView(withText("Permanently delete all data from all time?")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView(withText("Delete")).inRoot(isDialog()).perform(click()) + + onView(withText("Deleting your data")).check(doesNotExist()) + } + + @Test + fun deleteFragment_deletionSuccessfulState_successMessageShown() { + val deletionTypeAllData = DeletionType.DeletionTypeAllData() + + Mockito.`when`(viewModel.deletionParameters).then { + MutableLiveData( + DeletionParameters( + deletionState = DeletionState.STATE_DELETION_SUCCESSFUL, + deletionType = deletionTypeAllData, + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA)) + } + + launchFragment<DeletionFragment>(Bundle()) { + (this as DeletionFragment) + .parentFragmentManager + .setFragmentResult( + START_DELETION_EVENT, bundleOf(DELETION_TYPE to deletionTypeAllData)) + } + + onView(withId(R.id.radio_button_all)).inRoot(isDialog()).perform(click()) + + onView(withText("Next")).inRoot(isDialog()).perform(click()) + + onView(withText("Permanently delete all data from all time?")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView(withText("Delete")).inRoot(isDialog()).perform(click()) + + onView(withText("Deleting your data")).inRoot(isDialog()).check(doesNotExist()) + onView(withText("Data deleted")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("This data is no longer stored in Health\u00A0Connect.")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + + @Test + fun deleteFragment_deletionFailedState_failureMessageShown() { + val deletionTypeAllData = DeletionType.DeletionTypeAllData() + + Mockito.`when`(viewModel.deletionParameters).then { + MutableLiveData( + DeletionParameters( + deletionState = DeletionState.STATE_DELETION_FAILED, + deletionType = deletionTypeAllData, + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA)) + } + + launchFragment<DeletionFragment>(Bundle()) { + (this as DeletionFragment) + .parentFragmentManager + .setFragmentResult( + START_DELETION_EVENT, bundleOf(DELETION_TYPE to deletionTypeAllData)) + } + + onView(withId(R.id.radio_button_all)).inRoot(isDialog()).perform(click()) + + onView(withText("Next")).inRoot(isDialog()).perform(click()) + + onView(withText("Permanently delete all data from all time?")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView( + withText( + "Connected apps will no longer be able to access this data from Health\u00A0Connect")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + onView(withText("Delete")).inRoot(isDialog()).perform(click()) + + onView(withText("Deleting your data")).inRoot(isDialog()).check(doesNotExist()) + onView(withText("Couldn't delete data")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText("Something went wrong and Health\u00A0Connect couldn't delete your data")) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionParametersTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionParametersTest.kt index 79fa525c..903c4e9c 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionParametersTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/deletion/DeletionParametersTest.kt @@ -6,9 +6,15 @@ import com.android.healthconnect.controller.deletion.ChosenRange import com.android.healthconnect.controller.deletion.DeletionParameters import com.android.healthconnect.controller.deletion.DeletionState import com.android.healthconnect.controller.deletion.DeletionType +import com.android.healthconnect.controller.permissions.data.HealthPermissionStrings import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.tests.utils.TEST_APP_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import java.lang.IllegalStateException +import java.time.Duration import java.time.Instant import junit.framework.Assert.assertTrue +import org.junit.Assert.assertThrows import org.junit.Test class DeletionParametersTest { @@ -98,4 +104,114 @@ class DeletionParametersTest { assertTrue( deletionParameters.showTimeRangePickerDialog == outValue.showTimeRangePickerDialog) } + + @Test + fun getPermissionTypeLabel_permissionTypeFromApp_correctPermissionLabelReturned() { + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_LAST_24_HOURS, + deletionType = + DeletionType.DeletionTypeHealthPermissionTypeFromApp( + HealthPermissionType.ACTIVE_CALORIES_BURNED, + packageName = TEST_APP_PACKAGE_NAME, + appName = TEST_APP_NAME), + ) + + assertTrue( + deletionParameters.getPermissionTypeLabel() == + HealthPermissionStrings.fromPermissionType( + HealthPermissionType.ACTIVE_CALORIES_BURNED) + .lowercaseLabel) + } + + @Test + fun getPermissionTypeLabel_permissionTypeDoesNotExist_errorThrown() { + val deletionParameters = DeletionParameters() + + assertThrows(IllegalStateException::class.java) { + deletionParameters.getPermissionTypeLabel() + } + } + + @Test + fun getCategoryLabel_categoryDataDoesNotExist_errorThrown() { + val deletionParameters = DeletionParameters() + + assertThrows(IllegalStateException::class.java) { deletionParameters.getCategoryLabel() } + } + + @Test + fun getStartTimeInstant_24HoursRangeSelected_correctStartTimeReturned() { + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_LAST_24_HOURS, + deletionType = DeletionType.DeletionTypeAllData()) + + assertTrue( + deletionParameters.getStartTimeInstant() == + deletionParameters.getEndTimeInstant().minus(Duration.ofDays(1))) + } + + @Test + fun getStartTimeInstant_7DaysRangeSelected_correctStartTimeReturned() { + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_LAST_7_DAYS, + deletionType = DeletionType.DeletionTypeAllData()) + + assertTrue( + deletionParameters.getStartTimeInstant() == + deletionParameters.getEndTimeInstant().minus(Duration.ofDays(7))) + } + + @Test + fun getStartTimeInstant_30DaysRangeSelected_correctStartTimeReturned() { + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_LAST_30_DAYS, + deletionType = DeletionType.DeletionTypeAllData()) + + assertTrue( + deletionParameters.getStartTimeInstant() == + deletionParameters.getEndTimeInstant().minus(Duration.ofDays(30))) + } + + @Test + fun getStartTimeInstant_allTimeRangeSelected_correctStartTimeReturned() { + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA, + deletionType = DeletionType.DeletionTypeAllData()) + + assertTrue(deletionParameters.getStartTimeInstant() == Instant.EPOCH) + } + + @Test + fun getEndTimeInstant_24HoursRangeSelected_correctEndTimeReturned() { + val startTime = Instant.parse("2022-11-11T20:00:00.000Z") + val endTime = Instant.parse("2022-11-14T20:00:00.000Z") + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_LAST_7_DAYS, + startTimeMs = startTime.toEpochMilli(), + endTimeMs = endTime.toEpochMilli(), + deletionType = DeletionType.DeletionTypeAllData()) + + assertTrue( + deletionParameters.getEndTimeInstant() == Instant.ofEpochMilli(endTime.toEpochMilli())) + } + + @Test + fun getEndTimeInstant_allTimeRangeSelected_correctEndTimeReturned() { + val startTime = Instant.parse("2022-11-11T20:00:00.000Z") + val endTime = Instant.parse("2022-11-14T20:00:00.000Z") + val deletionParameters = + DeletionParameters( + chosenRange = ChosenRange.DELETE_RANGE_ALL_DATA, + startTimeMs = startTime.toEpochMilli(), + endTimeMs = endTime.toEpochMilli(), + deletionType = DeletionType.DeletionTypeAllData()) + + assertTrue(deletionParameters.getEndTimeInstant() == Instant.ofEpochMilli(Long.MAX_VALUE)) + } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/managedata/ManageDataFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/managedata/ManageDataFragmentTest.kt index 1e5e90a6..59196e2a 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/managedata/ManageDataFragmentTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/managedata/ManageDataFragmentTest.kt @@ -17,27 +17,27 @@ import com.android.healthconnect.controller.utils.FeatureUtils import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito -import javax.inject.Inject @HiltAndroidTest class ManageDataFragmentTest { - @get:Rule - val hiltRule = HiltAndroidRule(this) + @get:Rule val hiltRule = HiltAndroidRule(this) @BindValue val autoDeleteViewModel: AutoDeleteViewModel = Mockito.mock(AutoDeleteViewModel::class.java) - @Inject - lateinit var fakeFeatureUtils: FeatureUtils + @Inject lateinit var fakeFeatureUtils: FeatureUtils @Before fun setup() { hiltRule.inject() whenever(autoDeleteViewModel.storedAutoDeleteRange).then { - MutableLiveData(AutoDeleteViewModel.AutoDeleteState.WithData(AutoDeleteRange.AUTO_DELETE_RANGE_NEVER)) + MutableLiveData( + AutoDeleteViewModel.AutoDeleteState.WithData( + AutoDeleteRange.AUTO_DELETE_RANGE_NEVER)) } } @@ -47,7 +47,7 @@ class ManageDataFragmentTest { launchFragment<ManageDataFragment>(Bundle()) onView(withText("Auto-delete")).check(matches(isDisplayed())) - onView(withText("Data sources & priority")).check(matches(isDisplayed())) + onView(withText("Data sources and priority")).check(matches(isDisplayed())) onView(withText("Set units")).check(matches(isDisplayed())) } @@ -57,7 +57,7 @@ class ManageDataFragmentTest { launchFragment<ManageDataFragment>(Bundle()) onView(withText("Auto-delete")).check(matches(isDisplayed())) - onView(withText("Data sources & priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) onView(withText("Set units")).check(matches(isDisplayed())) } -}
\ No newline at end of file +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/migration/AppUpdateRequiredFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/migration/AppUpdateRequiredFragmentTest.kt new file mode 100644 index 00000000..3ce5df5f --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/migration/AppUpdateRequiredFragmentTest.kt @@ -0,0 +1,113 @@ +package com.android.healthconnect.controller.tests.migration + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasPackage +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.android.healthconnect.controller.migration.AppUpdateRequiredFragment +import com.android.healthconnect.controller.tests.utils.launchFragment +import com.android.healthconnect.controller.tests.utils.whenever +import com.android.healthconnect.controller.utils.AppStoreUtils +import com.android.healthconnect.controller.utils.NavigationUtils +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.doNothing +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@HiltAndroidTest +class AppUpdateRequiredFragmentTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @BindValue val appStoreUtils: AppStoreUtils = Mockito.mock(AppStoreUtils::class.java) + @BindValue val navigationUtils: NavigationUtils = Mockito.mock(NavigationUtils::class.java) + + @Before + fun setup() { + hiltRule.inject() + Intents.init() + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + fun appUpdateRequiredFragment_displaysCorrectly() { + launchFragment<AppUpdateRequiredFragment>(Bundle()) + + onView(withText("Update needed")).check(matches(isDisplayed())) + onView( + withText( + "Health Connect is being integrated with the Android system so " + + "you can access it directly from your settings.")) + .check(matches(isDisplayed())) + onView(withText("Before continuing, update the Health Connect app to the latest version.")) + .check(matches(isDisplayed())) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Update")).check(matches(isDisplayed())) + } + + @Test + fun appUpdateRequiredFragment_ifAppStoreExists_intentToAppStore() { + whenever(appStoreUtils.getAppStoreLink(any())) + .thenReturn( + Intent(Intent.ACTION_SHOW_APP_INFO).also { + it.setPackage("installer.package.name") + }) + whenever(navigationUtils.startActivity(any(), any())).thenCallRealMethod() + launchFragment<AppUpdateRequiredFragment>(Bundle()) + onView(withText("Update")).check(matches(isDisplayed())) + onView(withText("Update")).perform(click()) + + intended( + allOf(hasAction(Intent.ACTION_SHOW_APP_INFO), hasPackage("installer.package.name"))) + } + + @Test + fun appUpdateRequiredFragment_ifAppStoreDoesNotExist_doesNotNavigateToAppStore() { + whenever(appStoreUtils.getAppStoreLink(any())).thenReturn(null) + + launchFragment<AppUpdateRequiredFragment>(Bundle()) + onView(withText("Update")).check(matches(isDisplayed())) + onView(withText("Update")).perform(click()) + + // Check we are still on the same page + onView(withText("Before continuing, update the Health Connect app to the latest version.")) + .check(matches(isDisplayed())) + + verify(navigationUtils, never()).startActivity(any(), any()) + } + + @Test + fun appUpdateRequiredFragment_whenCancelButtonPressed_setsSharedPreferences() { + doNothing().whenever(navigationUtils).navigate(any(), any()) + val scenario = launchFragment<AppUpdateRequiredFragment>(Bundle()) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Cancel")).perform(click()) + + scenario.onActivity { activity -> + val preferences = + activity.getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) + assertThat(preferences.getBoolean("App Update Seen", false)).isTrue() + } + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationInProgressFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationInProgressFragmentTest.kt new file mode 100644 index 00000000..4cd9bdc9 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationInProgressFragmentTest.kt @@ -0,0 +1,36 @@ +package com.android.healthconnect.controller.tests.migration + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.android.healthconnect.controller.migration.MigrationInProgressFragment +import com.android.healthconnect.controller.tests.utils.launchFragment +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class MigrationInProgressFragmentTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun migrationInProgressFragment_displaysCorrectly() { + launchFragment<MigrationInProgressFragment>() + + onView(withText("Integration in progress")).check(matches(isDisplayed())) + onView( + withText( + "Health Connect is being integrated with the Android system." + + "\n\nIt may take some time while your data and permissions are being transferred.")) + .check(matches(isDisplayed())) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationNavigationFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationNavigationFragmentTest.kt new file mode 100644 index 00000000..3a586710 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationNavigationFragmentTest.kt @@ -0,0 +1,217 @@ +package com.android.healthconnect.controller.tests.migration + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.android.healthconnect.controller.R +import com.android.healthconnect.controller.migration.MigrationNavigationFragment +import com.android.healthconnect.controller.migration.MigrationViewModel +import com.android.healthconnect.controller.migration.api.MigrationState +import com.android.healthconnect.controller.tests.utils.launchFragment +import com.android.healthconnect.controller.tests.utils.whenever +import com.android.healthconnect.controller.utils.NavigationUtils +import com.google.common.truth.Truth +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@HiltAndroidTest +class MigrationNavigationFragmentTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @BindValue val navigationUtils: NavigationUtils = Mockito.mock(NavigationUtils::class.java) + @BindValue + val migrationViewModel: MigrationViewModel = Mockito.mock(MigrationViewModel::class.java) + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun migrationNavigationFragment_whenMigrationLoading_showsLoading() { + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.Loading) + } + + launchFragment<MigrationNavigationFragment>() + + onView(withId(R.id.progress_indicator)).check(matches(isDisplayed())) + } + + @Test + fun migrationNavigationFragment_whenMigrationError_showsError() { + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.Error) + } + + launchFragment<MigrationNavigationFragment>() + + onView(withId(R.id.error_view)).check(matches(isDisplayed())) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateAllowedNotStarted_navigatesToMigrationPausedFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData( + MigrationState.ALLOWED_NOT_STARTED)) + } + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_migrationPausedFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateAllowedPaused_navigatesToMigrationPausedFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.ALLOWED_PAUSED)) + } + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_migrationPausedFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateAppUpdateRequired_navigatesToAppUpdateRequiredFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData( + MigrationState.APP_UPGRADE_REQUIRED)) + } + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate( + any(), + eq(R.id.action_migrationNavigationFragment_to_migrationAppUpdateNeededFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateModuleUpdateRequired_navigatesToModuleUpdateRequiredFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData( + MigrationState.MODULE_UPGRADE_REQUIRED)) + } + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate( + any(), + eq(R.id.action_migrationNavigationFragment_to_migrationModuleUpdateNeededFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateInProgress_navigatesToMigrationInProgressFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.IN_PROGRESS)) + } + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate( + any(), eq(R.id.action_migrationNavigationFragment_to_migrationInProgressFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateCompleteIdle_setsPreferenceAndNavigatesToHomeFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.COMPLETE_IDLE)) + } + val scenario = launchFragment<MigrationNavigationFragment>() + scenario.onActivity { activity -> + val preferences = + activity.getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) + Truth.assertThat(preferences.getBoolean("migration_complete_key", false)).isTrue() + } + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_homeFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateComplete_setsPreferenceAndNavigatesToHomeFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.COMPLETE)) + } + val scenario = launchFragment<MigrationNavigationFragment>() + scenario.onActivity { activity -> + val preferences = + activity.getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) + Truth.assertThat(preferences.getBoolean("migration_complete_key", false)).isTrue() + } + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_homeFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateIdle_navigatesToHomeFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.IDLE)) + } + + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_homeFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateAllowedMigratorDisabled_navigatesToHomeFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData( + MigrationState.ALLOWED_MIGRATOR_DISABLED)) + } + + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_homeFragment)) + } + + @Test + fun migrationNavigationFragment_whenMigrationStateUnknown_navigatesToHomeFragment() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + whenever(migrationViewModel.migrationState).then { + MutableLiveData<MigrationViewModel.MigrationFragmentState>( + MigrationViewModel.MigrationFragmentState.WithData(MigrationState.UNKNOWN)) + } + + launchFragment<MigrationNavigationFragment>() + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationNavigationFragment_to_homeFragment)) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationPausedFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationPausedFragmentTest.kt new file mode 100644 index 00000000..c120f645 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/migration/MigrationPausedFragmentTest.kt @@ -0,0 +1,96 @@ +package com.android.healthconnect.controller.tests.migration + +import android.content.Context +import android.os.Bundle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.android.healthconnect.controller.R +import com.android.healthconnect.controller.migration.MigrationPausedFragment +import com.android.healthconnect.controller.tests.utils.launchFragment +import com.android.healthconnect.controller.tests.utils.whenever +import com.android.healthconnect.controller.utils.NavigationUtils +import com.google.common.truth.Truth +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@HiltAndroidTest +class MigrationPausedFragmentTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @BindValue val navigationUtils: NavigationUtils = Mockito.mock(NavigationUtils::class.java) + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun migrationPausedFragment_displaysCorrectly() { + launchFragment<MigrationPausedFragment>() + + onView(withText("Integration paused")).check(matches(isDisplayed())) + onView( + withText( + "The Health Connect app closed while it was being integrated " + + "with the Android system.\n\nClick resume to reopen the app and continue " + + "transferring your data and permissions.")) + .check(matches(isDisplayed())) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Resume")).check(matches(isDisplayed())) + } + + @Test + fun migrationPausedFragment_whenCancelButtonPressed_setsSharedPreferences() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + val scenario = launchFragment<MigrationPausedFragment>(Bundle()) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Cancel")).perform(ViewActions.click()) + + scenario.onActivity { activity -> + val preferences = + activity.getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) + Truth.assertThat(preferences.getBoolean("integration_paused_seen", false)).isTrue() + } + } + + @Test + fun migrationPausedFragment_whenResumeButtonPressed_navigatesToMigratorApk() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + launchFragment<MigrationPausedFragment>(Bundle()) + onView(withText("Resume")).check(matches(isDisplayed())) + onView(withText("Resume")).perform(ViewActions.click()) + + verify(navigationUtils, times(1)) + .navigate(any(), eq(R.id.action_migrationPausedFragment_to_migrationApk)) + } + + @Test + fun migrationPausedFragment_whenNavigateToMigratorApkFails_displaysCorrectly() { + whenever(navigationUtils.navigate(any(), any())).thenThrow(RuntimeException("Exception")) + launchFragment<MigrationPausedFragment>(Bundle()) + onView(withText("Resume")).check(matches(isDisplayed())) + onView(withText("Resume")).perform(ViewActions.click()) + + onView(withText("Integration paused")).check(matches(isDisplayed())) + onView( + withText( + "The Health Connect app closed while it was being integrated " + + "with the Android system.\n\nClick resume to reopen the app and continue " + + "transferring your data and permissions.")) + .check(matches(isDisplayed())) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Resume")).check(matches(isDisplayed())) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/migration/ModuleUpdateRequiredFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/migration/ModuleUpdateRequiredFragmentTest.kt new file mode 100644 index 00000000..8c19ca61 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/migration/ModuleUpdateRequiredFragmentTest.kt @@ -0,0 +1,117 @@ +package com.android.healthconnect.controller.tests.migration + +import android.content.Context +import android.os.Bundle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.android.healthconnect.controller.R +import com.android.healthconnect.controller.migration.ModuleUpdateRequiredFragment +import com.android.healthconnect.controller.tests.utils.launchFragment +import com.android.healthconnect.controller.tests.utils.whenever +import com.android.healthconnect.controller.utils.NavigationUtils +import com.google.common.truth.Truth +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@HiltAndroidTest +class ModuleUpdateRequiredFragmentTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + @BindValue val navigationUtils: NavigationUtils = Mockito.mock(NavigationUtils::class.java) + + @Before + fun setup() { + hiltRule.inject() + Intents.init() + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + fun moduleUpdateRequiredFragment_displaysCorrectly() { + launchFragment<ModuleUpdateRequiredFragment>() + + onView(withText("Update needed")).check(matches(isDisplayed())) + onView( + withText( + "Health Connect is being integrated with the Android system so " + + "you can access it directly from your settings.")) + .check(matches(isDisplayed())) + onView(withText("Before continuing, update your phone system.")) + .check(matches(isDisplayed())) + onView( + withText( + "If you\'ve already updated your phone system, " + + "try restarting your phone to continue the integration")) + .check(matches(isDisplayed())) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Update")).check(matches(isDisplayed())) + } + + @Test + fun moduleUpdateRequiredFragment_whenCancelButtonPressed_setsSharedPreferences() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + val scenario = launchFragment<ModuleUpdateRequiredFragment>(Bundle()) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Cancel")).perform(ViewActions.click()) + + scenario.onActivity { activity -> + val preferences = + activity.getSharedPreferences("USER_ACTIVITY_TRACKER", Context.MODE_PRIVATE) + Truth.assertThat(preferences.getBoolean("Module Update Seen", false)).isTrue() + } + } + + @Test + fun moduleUpdateRequiredFragment_whenUpdateButtonPressed_navigatesToSystemUpdate() { + Mockito.doNothing().whenever(navigationUtils).navigate(any(), any()) + launchFragment<ModuleUpdateRequiredFragment>(Bundle()) + onView(withText("Update")).check(matches(isDisplayed())) + onView(withText("Update")).perform(ViewActions.click()) + + verify(navigationUtils, times(1)) + .navigate( + any(), eq(R.id.action_migrationModuleUpdateNeededFragment_to_systemUpdateActivity)) + } + + @Test + fun moduleUpdateRequiredFragment_whenNavigateToSystemUpdateFails_displaysCorrectly() { + whenever(navigationUtils.navigate(any(), any())).thenThrow(RuntimeException("Exception")) + launchFragment<ModuleUpdateRequiredFragment>(Bundle()) + onView(withText("Update")).check(matches(isDisplayed())) + onView(withText("Update")).perform(ViewActions.click()) + + onView(withText("Update needed")).check(matches(isDisplayed())) + onView( + withText( + "Health Connect is being integrated with the Android system so " + + "you can access it directly from your settings.")) + .check(matches(isDisplayed())) + onView(withText("Before continuing, update your phone system.")) + .check(matches(isDisplayed())) + onView( + withText( + "If you\'ve already updated your phone system, " + + "try restarting your phone to continue the integration")) + .check(matches(isDisplayed())) + onView(withText("Cancel")).check(matches(isDisplayed())) + onView(withText("Update")).check(matches(isDisplayed())) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/onboarding/OnboardingScreenTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/onboarding/OnboardingScreenTest.kt index c6b4719e..dffc80ac 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/onboarding/OnboardingScreenTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/onboarding/OnboardingScreenTest.kt @@ -21,7 +21,6 @@ import android.content.Intent import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.* import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.scrollTo @@ -41,6 +40,7 @@ import org.junit.Test @HiltAndroidTest class OnboardingScreenTest { @get:Rule val hiltRule = HiltAndroidRule(this) + @Before fun setup() { hiltRule.inject() @@ -92,7 +92,7 @@ class OnboardingScreenTest { onIdle() onView(withId(R.id.go_back_button)).perform(ViewActions.click()) Thread.sleep(4_000) // Need to wait for Activity to close before checking state - assertEquals(Lifecycle.State.DESTROYED, scenario.state) + assertEquals(Lifecycle.State.DESTROYED, scenario.getState()) } @Test diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetGrantedHealthPermissionsUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetGrantedHealthPermissionsUseCaseTest.kt index 5c8d229f..e1f5524e 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetGrantedHealthPermissionsUseCaseTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetGrantedHealthPermissionsUseCaseTest.kt @@ -19,10 +19,13 @@ import android.content.Context import androidx.test.platform.app.InstrumentationRegistry import com.android.healthconnect.controller.permissions.api.GetGrantedHealthPermissionsUseCase import com.android.healthconnect.controller.permissions.api.HealthPermissionManager +import com.android.healthconnect.controller.tests.utils.whenever +import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import org.mockito.kotlin.any class GetGrantedHealthPermissionsUseCaseTest { @@ -43,4 +46,25 @@ class GetGrantedHealthPermissionsUseCaseTest { verify(healthPermissionManager).getGrantedHealthPermissions("TEST_APP") } + + @Test + fun invoke_callsHealthPermissionManager_returnsCorrectList() { + val expectedList = listOf("permission1", "permission2") + whenever(healthPermissionManager.getGrantedHealthPermissions(any())) + .thenReturn(expectedList) + val result = useCase.invoke("TEST_APP") + + verify(healthPermissionManager).getGrantedHealthPermissions("TEST_APP") + assertThat(result).containsExactlyElementsIn(expectedList) + } + + @Test + fun invoke_whenHealthPermissionManagerFails_returnsEmptyList() { + whenever(healthPermissionManager.getGrantedHealthPermissions(any())) + .thenThrow(RuntimeException("Error!")) + val result = useCase.invoke("TEST_APP") + + verify(healthPermissionManager).getGrantedHealthPermissions("TEST_APP") + assertThat(result).isEmpty() + } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetHealthPermissionsFlagsUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetHealthPermissionsFlagsUseCaseTest.kt index dfd3790b..296c7ed6 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetHealthPermissionsFlagsUseCaseTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/GetHealthPermissionsFlagsUseCaseTest.kt @@ -20,10 +20,13 @@ import android.content.Context import androidx.test.platform.app.InstrumentationRegistry import com.android.healthconnect.controller.permissions.api.GetHealthPermissionsFlagsUseCase import com.android.healthconnect.controller.permissions.api.HealthPermissionManager +import com.android.healthconnect.controller.tests.utils.whenever +import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.mockito.Mockito import org.mockito.Mockito.verify +import org.mockito.kotlin.any class GetHealthPermissionsFlagsUseCaseTest { private lateinit var context: Context @@ -45,4 +48,14 @@ class GetHealthPermissionsFlagsUseCaseTest { .getHealthPermissionsFlags( "TEST_APP", listOf("PERMISSION_1", "PERMISSION_2", "PERMISSION_3")) } + + @Test + fun invoke_whenHealthPermissionManagerFails_returnsEmptyMap() { + whenever(healthPermissionManager.getHealthPermissionsFlags(any(), any())) + .thenThrow(RuntimeException("Exception")) + + val result = + useCase.invoke("TEST_APP", listOf("PERMISSION_1", "PERMISSION_2", "PERMISSION_3")) + assertThat(result).isEmpty() + } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/HealthPermissionManagerImplTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/HealthPermissionManagerImplTest.kt new file mode 100644 index 00000000..24da0d96 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/HealthPermissionManagerImplTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.healthconnect.controller.tests.permissions.api + +import android.health.connect.HealthConnectManager +import com.android.healthconnect.controller.permissions.api.HealthPermissionManagerImpl +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@HiltAndroidTest +class HealthPermissionManagerImplTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + private val healthConnectManager = Mockito.mock(HealthConnectManager::class.java) + + private lateinit var healthPermissionManager: HealthPermissionManagerImpl + + @Before + fun setup() { + hiltRule.inject() + healthPermissionManager = HealthPermissionManagerImpl(healthConnectManager) + } + + @Test + fun getGrantedHealthPermissions_callsHealthConnectManager() { + healthPermissionManager.getGrantedHealthPermissions("packageName") + + verify(healthConnectManager, times(1)).getGrantedHealthPermissions("packageName") + } + + @Test + fun getHealthPermissionsFlags_callsHealthConnectManager() { + val packageName = "package.name" + val permissions = listOf("Permission 1", "Permission 2") + healthPermissionManager.getHealthPermissionsFlags(packageName, permissions) + + verify(healthConnectManager, times(1)).getHealthPermissionsFlags(packageName, permissions) + } + + @Test + fun makeHealthPermissionsRequestable_callsHealthConnectManager() { + val packageName = "package.name" + val permissions = listOf("Permission 1", "Permission 2") + healthPermissionManager.makeHealthPermissionsRequestable(packageName, permissions) + + verify(healthConnectManager, times(1)) + .makeHealthPermissionsRequestable(packageName, permissions) + } + + @Test + fun grantHealthPermission_callsHealthConnectManager() { + val packageName = "package.name" + val permission = "Permission 1" + healthPermissionManager.grantHealthPermission(packageName, permission) + + verify(healthConnectManager, times(1)).grantHealthPermission(packageName, permission) + } + + @Test + fun revokeHealthPermission_callsHealthConnectManager() { + val packageName = "package.name" + val permission = "Permission 1" + val reason = "" + + healthPermissionManager.revokeHealthPermission(packageName, permission) + + verify(healthConnectManager, times(1)) + .revokeHealthPermission(packageName, permission, reason) + } + + @Test + fun revokeAllHealthPermissions_callsHealthConnectManager() { + val packageName = "package.name" + val reason = "" + healthPermissionManager.revokeAllHealthPermissions(packageName) + + verify(healthConnectManager, times(1)).revokeAllHealthPermissions(packageName, reason) + } + + @Test + fun loadStartAccessDate_callsHealthConnectManager() { + val packageName = "package.name" + healthPermissionManager.loadStartAccessDate(packageName) + + verify(healthConnectManager, times(1)).getHealthDataHistoricalAccessStartDate(packageName) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/LoadAccessDateUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/LoadAccessDateUseCaseTest.kt new file mode 100644 index 00000000..38526274 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissions/api/LoadAccessDateUseCaseTest.kt @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * ``` + * http://www.apache.org/licenses/LICENSE-2.0 + * ``` + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.android.healthconnect.controller.tests.permissions.api + +import com.android.healthconnect.controller.permissions.api.HealthPermissionManager +import com.android.healthconnect.controller.permissions.api.LoadAccessDateUseCase +import com.android.healthconnect.controller.tests.utils.whenever +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.Instant +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any + +@HiltAndroidTest +class LoadAccessDateUseCaseTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + private val healthPermissionManager = Mockito.mock(HealthPermissionManager::class.java) + + private lateinit var loadAccessDateUseCase: LoadAccessDateUseCase + + @Before + fun setup() { + hiltRule.inject() + loadAccessDateUseCase = LoadAccessDateUseCase(healthPermissionManager) + } + + @Test + fun loadAccessDate_callsHealthPermissionManager() { + val expected = Instant.parse("2023-04-16T12:00:00Z") + whenever(healthPermissionManager.loadStartAccessDate(any())).thenReturn(expected) + + val result = loadAccessDateUseCase.invoke("package.name") + assertThat(result).isEqualTo(expected) + } + + @Test + fun loadAccessDate_whenHealthPermissionManagerFails_returnsNull() { + whenever(healthPermissionManager.loadStartAccessDate(any())) + .thenThrow(RuntimeException("Exception")) + + val result = loadAccessDateUseCase.invoke("package.name") + assertThat(result).isNull() + } + + @Test + fun loadAccessDate_whenPackageNameNull_returnsNull() { + val result = loadAccessDateUseCase.invoke(null) + assertThat(result).isNull() + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/HealthPermissionTypesFragmentTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/HealthPermissionTypesFragmentTest.kt index 767890ec..62fb37fc 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/HealthPermissionTypesFragmentTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/HealthPermissionTypesFragmentTest.kt @@ -15,6 +15,7 @@ */ package com.android.healthconnect.controller.tests.permissiontypes +import android.health.connect.HealthDataCategory import android.os.Bundle import androidx.lifecycle.MutableLiveData import androidx.test.espresso.Espresso.onView @@ -23,34 +24,38 @@ import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import com.android.healthconnect.controller.R import com.android.healthconnect.controller.categories.HealthDataCategoriesFragment import com.android.healthconnect.controller.permissions.data.HealthPermissionType import com.android.healthconnect.controller.permissiontypes.HealthPermissionTypesFragment import com.android.healthconnect.controller.permissiontypes.HealthPermissionTypesViewModel +import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.lowercaseTitle import com.android.healthconnect.controller.shared.app.AppMetadata import com.android.healthconnect.controller.tests.utils.TEST_APP import com.android.healthconnect.controller.tests.utils.TEST_APP_2 import com.android.healthconnect.controller.tests.utils.TEST_APP_3 +import com.android.healthconnect.controller.tests.utils.atPosition import com.android.healthconnect.controller.tests.utils.di.FakeFeatureUtils import com.android.healthconnect.controller.tests.utils.launchFragment import com.android.healthconnect.controller.utils.FeatureUtils import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito -import javax.inject.Inject @HiltAndroidTest class HealthPermissionTypesFragmentTest { @get:Rule val hiltRule = HiltAndroidRule(this) - @Inject - lateinit var fakeFeatureUtils: FeatureUtils + @Inject lateinit var fakeFeatureUtils: FeatureUtils @BindValue val viewModel: HealthPermissionTypesViewModel = @@ -63,7 +68,7 @@ class HealthPermissionTypesFragmentTest { } @Test - fun permissionTypesFragment_isDisplayed() { + fun permissionTypesFragment_activityCategory_isDisplayed() { Mockito.`when`(viewModel.permissionTypesData).then { MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( HealthPermissionTypesViewModel.PermissionTypesState.WithData( @@ -102,6 +107,39 @@ class HealthPermissionTypesFragmentTest { onView(withText("App priority")).check(matches(isDisplayed())) onView(withText("Health Connect test app")).check(matches(isDisplayed())) onView(withText("Delete activity data")).check(matches(isDisplayed())) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_sleepCategory_isDisplayed() { + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.SLEEP))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.SLEEP.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(sleepCategoryBundle()) + + onView(withText("Manage data")).check(matches(isDisplayed())) + onView(withText("App priority")).check(matches(isDisplayed())) + onView(withText("Health Connect test app")).check(matches(isDisplayed())) + onView(withText("Delete sleep data")).check(matches(isDisplayed())) + onView(withText("Data sources and priority")).check(doesNotExist()) } @Test @@ -143,6 +181,7 @@ class HealthPermissionTypesFragmentTest { onView(withText("App priority")).check(doesNotExist()) onView(withText("Health Connect test app")).check(doesNotExist()) onView(withText("Delete activity data")).perform(scrollTo()).check(matches(isDisplayed())) + onView(withText("Data sources and priority")).check(doesNotExist()) } @Test @@ -182,6 +221,7 @@ class HealthPermissionTypesFragmentTest { onView(withText("App priority")).check(doesNotExist()) onView(withText("Health Connect test app")).check(doesNotExist()) onView(withText("Delete activity data")).perform(scrollTo()).check(matches(isDisplayed())) + onView(withText("Data sources and priority")).check(doesNotExist()) } @Test @@ -230,6 +270,44 @@ class HealthPermissionTypesFragmentTest { } @Test + fun permissionTypesFragment_priorityListDialog_priorityListChanged_appsAreArrangedCorrectly() { + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf( + HealthPermissionType.DISTANCE, + HealthPermissionType.EXERCISE, + HealthPermissionType.STEPS))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData(listOf(TEST_APP_2, TEST_APP)) + } + Mockito.`when`(viewModel.categoryLabel).then { MutableLiveData("activity") } + + val expectedAppsOrder = listOf("Health Connect test app 2", "Health Connect test app") + + launchFragment<HealthPermissionTypesFragment>(activityCategoryBundle()) + + onView(withText("App priority")).perform(click()) + + for ((index, expectedItem) in expectedAppsOrder.withIndex()) { + onView(withId(R.id.priority_list_recycle_view)) + .inRoot(isDialog()) + .check(matches(atPosition(index, hasDescendant(withText(expectedItem))))) + } + } + + @Test fun permissionTypesFragment_withTwoOrMoreContributingApps_appFilters_areDisplayed() { Mockito.`when`(viewModel.permissionTypesData).then { MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( @@ -315,7 +393,7 @@ class HealthPermissionTypesFragmentTest { } @Test - fun permissionTypesFragment_whenNewPriorityEnabled_doesNotShowAppPriority() { + fun permissionTypesFragment_activityCategory_whenNewPriorityEnabled_showsNewAppPriorityButton() { (fakeFeatureUtils as FakeFeatureUtils).setIsNewAppPriorityEnabled(true) Mockito.`when`(viewModel.permissionTypesData).then { MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( @@ -353,13 +431,320 @@ class HealthPermissionTypesFragmentTest { onView(withText("Wheelchair pushes")).check(doesNotExist()) onView(withText("Manage data")).check(matches(isDisplayed())) onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(matches(isDisplayed())) onView(withText("Health Connect test app")).check(doesNotExist()) onView(withText("Delete activity data")).check(matches(isDisplayed())) } + @Test + fun permissionTypesFragment_sleepCategory_whenNewPriorityEnabled_showsNewAppPriorityButton() { + (fakeFeatureUtils as FakeFeatureUtils).setIsNewAppPriorityEnabled(true) + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.SLEEP))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.SLEEP.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(sleepCategoryBundle()) + + onView(withText("Manage data")).check(matches(isDisplayed())) + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(matches(isDisplayed())) + onView(withText("Health Connect test app")).check(doesNotExist()) + onView(withText("Delete sleep data")).check(matches(isDisplayed())) + } + + @Test + fun permissionTypesFragment_whenBodyMeasurementsCategory_doesNotShowOldPriorityButton() { + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf( + HealthPermissionType.BASAL_METABOLIC_RATE, + HealthPermissionType.BODY_FAT, + HealthPermissionType.HEIGHT))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.BODY_MEASUREMENTS.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(bodyMeasurementsCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenBodyMeasurementsCategory_doesNotShowNewPriorityButton() { + (fakeFeatureUtils as FakeFeatureUtils).setIsNewAppPriorityEnabled(true) + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf( + HealthPermissionType.BASAL_METABOLIC_RATE, + HealthPermissionType.BODY_FAT, + HealthPermissionType.HEIGHT))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.BODY_MEASUREMENTS.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(bodyMeasurementsCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenCycleTrackingCategory_doesNotShowOldPriorityButton() { + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.MENSTRUATION))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.CYCLE_TRACKING.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(cycleCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenCycleTrackingCategory_doesNotShowNewPriorityButton() { + (fakeFeatureUtils as FakeFeatureUtils).setIsNewAppPriorityEnabled(true) + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.MENSTRUATION))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.CYCLE_TRACKING.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(cycleCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenNutritionCategory_doesNotShowOldPriorityButton() { + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.NUTRITION))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.NUTRITION.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(nutritionCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenNutritionCategory_doesNotShowNewPriorityButton() { + (fakeFeatureUtils as FakeFeatureUtils).setIsNewAppPriorityEnabled(true) + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.NUTRITION))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.NUTRITION.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(nutritionCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenVitalsCategory_doesNotShowOldPriorityButton() { + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.HEART_RATE))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.VITALS.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(vitalsCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + + @Test + fun permissionTypesFragment_whenVitalsCategory_doesNotShowNewPriorityButton() { + (fakeFeatureUtils as FakeFeatureUtils).setIsNewAppPriorityEnabled(true) + Mockito.`when`(viewModel.permissionTypesData).then { + MutableLiveData<HealthPermissionTypesViewModel.PermissionTypesState>( + HealthPermissionTypesViewModel.PermissionTypesState.WithData( + listOf(HealthPermissionType.HEART_RATE))) + } + Mockito.`when`(viewModel.priorityList).then { + MutableLiveData<HealthPermissionTypesViewModel.PriorityListState>( + HealthPermissionTypesViewModel.PriorityListState.WithData( + listOf(TEST_APP, TEST_APP_2))) + } + Mockito.`when`(viewModel.appsWithData).then { + MutableLiveData<HealthPermissionTypesViewModel.AppsWithDataFragmentState>( + HealthPermissionTypesViewModel.AppsWithDataFragmentState.WithData(listOf())) + } + Mockito.`when`(viewModel.selectedAppFilter).then { MutableLiveData("") } + Mockito.`when`(viewModel.editedPriorityList).then { + MutableLiveData<List<AppMetadata>>(listOf(TEST_APP, TEST_APP_2)) + } + Mockito.`when`(viewModel.categoryLabel).then { + MutableLiveData(HealthDataCategory.VITALS.lowercaseTitle()) + } + launchFragment<HealthPermissionTypesFragment>(vitalsCategoryBundle()) + + onView(withText("App priority")).check(doesNotExist()) + onView(withText("Data sources and priority")).check(doesNotExist()) + } + private fun activityCategoryBundle(): Bundle { val bundle = Bundle() - bundle.putInt(HealthDataCategoriesFragment.CATEGORY_KEY, 1) + bundle.putInt(HealthDataCategoriesFragment.CATEGORY_KEY, HealthDataCategory.ACTIVITY) + return bundle + } + + private fun bodyMeasurementsCategoryBundle(): Bundle { + val bundle = Bundle() + bundle.putInt( + HealthDataCategoriesFragment.CATEGORY_KEY, HealthDataCategory.BODY_MEASUREMENTS) + return bundle + } + + private fun cycleCategoryBundle(): Bundle { + val bundle = Bundle() + bundle.putInt(HealthDataCategoriesFragment.CATEGORY_KEY, HealthDataCategory.CYCLE_TRACKING) + return bundle + } + + private fun nutritionCategoryBundle(): Bundle { + val bundle = Bundle() + bundle.putInt(HealthDataCategoriesFragment.CATEGORY_KEY, HealthDataCategory.NUTRITION) + return bundle + } + + private fun sleepCategoryBundle(): Bundle { + val bundle = Bundle() + bundle.putInt(HealthDataCategoriesFragment.CATEGORY_KEY, HealthDataCategory.SLEEP) + return bundle + } + + private fun vitalsCategoryBundle(): Bundle { + val bundle = Bundle() + bundle.putInt(HealthDataCategoriesFragment.CATEGORY_KEY, HealthDataCategory.VITALS) return bundle } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/FilterPermissionTypesUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/FilterPermissionTypesUseCaseTest.kt index bd513961..562cf00e 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/FilterPermissionTypesUseCaseTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/FilterPermissionTypesUseCaseTest.kt @@ -1,11 +1,10 @@ -package com.android.healthconnect.controller.tests.permissiontypes +package com.android.healthconnect.controller.tests.permissiontypes.api import android.content.Context import android.health.connect.HealthConnectManager import android.health.connect.HealthDataCategory import android.health.connect.HealthPermissionCategory import android.health.connect.RecordTypeInfoResponse -import android.health.connect.datatypes.DataOrigin import android.health.connect.datatypes.ExerciseLap import android.health.connect.datatypes.ExerciseSegment import android.health.connect.datatypes.ExerciseSessionRecord @@ -20,6 +19,7 @@ import com.android.healthconnect.controller.tests.utils.CoroutineTestRule import com.android.healthconnect.controller.tests.utils.NOW import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.getDataOrigin import com.android.healthconnect.controller.tests.utils.getMetaData import com.google.common.truth.Truth import dagger.hilt.android.testing.HiltAndroidRule @@ -158,8 +158,4 @@ class FilterPermissionTypesUseCaseTest { .setSegments(segments) .build() } - - private fun getDataOrigin(packageName: String): DataOrigin { - return DataOrigin.Builder().setPackageName(packageName).build() - } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadContributingAppsUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadContributingAppsUseCaseTest.kt new file mode 100644 index 00000000..e310b132 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadContributingAppsUseCaseTest.kt @@ -0,0 +1,138 @@ +package com.android.healthconnect.controller.tests.permissiontypes.api + +import android.content.Context +import android.health.connect.HealthConnectManager +import android.health.connect.HealthDataCategory +import android.health.connect.HealthPermissionCategory +import android.health.connect.RecordTypeInfoResponse +import android.health.connect.datatypes.ExerciseLap +import android.health.connect.datatypes.ExerciseSegment +import android.health.connect.datatypes.ExerciseSessionRecord +import android.health.connect.datatypes.ExerciseSessionType +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.StepsCadenceRecord +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.permissiontypes.api.LoadContributingAppsUseCase +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.tests.utils.CoroutineTestRule +import com.android.healthconnect.controller.tests.utils.NOW +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.getDataOrigin +import com.android.healthconnect.controller.tests.utils.getMetaData +import com.google.common.truth.Truth +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Matchers +import org.mockito.Mockito +import org.mockito.invocation.InvocationOnMock + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class LoadContributingAppsUseCaseTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private var manager: HealthConnectManager = Mockito.mock(HealthConnectManager::class.java) + @Inject lateinit var appInfoReader: AppInfoReader + private lateinit var usecase: LoadContributingAppsUseCase + private lateinit var context: Context + + @Before + fun setup() { + hiltRule.inject() + context = InstrumentationRegistry.getInstrumentation().context + usecase = LoadContributingAppsUseCase(appInfoReader, manager, Dispatchers.Main) + } + + @Test + fun loadContributingApps_contributingAppsLoadedCorrectly() = runTest { + val recordTypeInfo = + mapOf<Record, RecordTypeInfoResponse>( + getStepsCadence(listOf(10.3, 20.1)) to + RecordTypeInfoResponse( + HealthPermissionCategory.STEPS, + HealthDataCategory.ACTIVITY, + listOf(getDataOrigin(TEST_APP_PACKAGE_NAME))), + getRecord(type = ExerciseSessionType.EXERCISE_SESSION_TYPE_BIKING) to + RecordTypeInfoResponse( + HealthPermissionCategory.EXERCISE, + HealthDataCategory.ACTIVITY, + listOf(getDataOrigin(TEST_APP_PACKAGE_NAME_2)))) + + Mockito.doAnswer(prepareAnswer(recordTypeInfo)) + .`when`(manager) + .queryAllRecordTypesInfo(Matchers.any(), Matchers.any()) + + val loadedContributingApps = usecase.invoke(HealthDataCategory.ACTIVITY) + + Truth.assertThat(loadedContributingApps.size).isEqualTo(2) + Truth.assertThat(loadedContributingApps) + .containsExactlyElementsIn( + listOf( + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME), + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME_2))) + } + + @Test + fun loadContributingApps_failedToLoadData_emptyListReturned() = runTest { + Mockito.doThrow(RuntimeException()) + .`when`(manager) + .queryAllRecordTypesInfo(Matchers.any(), Matchers.any()) + + val loadedContributingApps = usecase.invoke(HealthDataCategory.ACTIVITY) + + Truth.assertThat(loadedContributingApps).isEmpty() + } + + private fun prepareAnswer( + recordTypeInfo: Map<Record, RecordTypeInfoResponse> + ): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[1] as OutcomeReceiver<Map<Record, RecordTypeInfoResponse>, *> + receiver.onResult(recordTypeInfo) + null + } + return answer + } + + private fun prepareNullAnswer(): (InvocationOnMock) -> Nothing? { + val answer = { _: InvocationOnMock -> null } + return answer + } + + private fun getStepsCadence(samples: List<Double>): StepsCadenceRecord { + return StepsCadenceRecord.Builder( + getMetaData(), + NOW, + NOW.plusSeconds(samples.size.toLong() + 1), + samples.map { rate -> + StepsCadenceRecord.StepsCadenceRecordSample(rate, NOW.plusSeconds(1)) + }) + .build() + } + + private fun getRecord( + type: Int = ExerciseSessionType.EXERCISE_SESSION_TYPE_BIKING, + title: String? = null, + note: String? = null, + laps: List<ExerciseLap> = emptyList(), + segments: List<ExerciseSegment> = emptyList() + ): ExerciseSessionRecord { + return ExerciseSessionRecord.Builder(getMetaData(), NOW, NOW.plusSeconds(1000), type) + .setNotes(note) + .setLaps(laps) + .setTitle(title) + .setSegments(segments) + .build() + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadPermissionTypesUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadPermissionTypesUseCaseTest.kt new file mode 100644 index 00000000..77d2174e --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadPermissionTypesUseCaseTest.kt @@ -0,0 +1,129 @@ +package com.android.healthconnect.controller.tests.permissiontypes.api + +import android.content.Context +import android.health.connect.HealthConnectManager +import android.health.connect.HealthDataCategory +import android.health.connect.HealthPermissionCategory +import android.health.connect.RecordTypeInfoResponse +import android.health.connect.datatypes.ExerciseLap +import android.health.connect.datatypes.ExerciseSegment +import android.health.connect.datatypes.ExerciseSessionRecord +import android.health.connect.datatypes.ExerciseSessionType +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.StepsCadenceRecord +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.permissiontypes.api.LoadPermissionTypesUseCase +import com.android.healthconnect.controller.tests.utils.CoroutineTestRule +import com.android.healthconnect.controller.tests.utils.NOW +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.getDataOrigin +import com.android.healthconnect.controller.tests.utils.getMetaData +import com.google.common.truth.Truth +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Matchers +import org.mockito.Mockito +import org.mockito.invocation.InvocationOnMock + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class LoadPermissionTypesUseCaseTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private var manager: HealthConnectManager = Mockito.mock(HealthConnectManager::class.java) + private lateinit var usecase: LoadPermissionTypesUseCase + private lateinit var context: Context + + @Before + fun setup() { + hiltRule.inject() + context = InstrumentationRegistry.getInstrumentation().context + usecase = LoadPermissionTypesUseCase(manager, Dispatchers.Main) + } + + @Test + fun loadPermissionTypes_permissionTypesUnderCategoryLoadedCorrectly() = runTest { + val recordTypeInfo = + mapOf<Record, RecordTypeInfoResponse>( + getStepsCadence(listOf(10.3, 20.1)) to + RecordTypeInfoResponse( + HealthPermissionCategory.STEPS, + HealthDataCategory.ACTIVITY, + listOf(getDataOrigin(TEST_APP_PACKAGE_NAME))), + getRecord(type = ExerciseSessionType.EXERCISE_SESSION_TYPE_BIKING) to + RecordTypeInfoResponse( + HealthPermissionCategory.EXERCISE, + HealthDataCategory.ACTIVITY, + listOf(getDataOrigin(TEST_APP_PACKAGE_NAME_2)))) + + Mockito.doAnswer(prepareAnswer(recordTypeInfo)) + .`when`(manager) + .queryAllRecordTypesInfo(Matchers.any(), Matchers.any()) + + val loadedContributingApps = usecase.invoke(HealthDataCategory.ACTIVITY) + + Truth.assertThat(loadedContributingApps.size).isEqualTo(2) + Truth.assertThat(loadedContributingApps) + .containsExactlyElementsIn( + listOf(HealthPermissionType.STEPS, HealthPermissionType.EXERCISE)) + } + + @Test + fun loadPermissionTypes_failedToLoadData_emptyListReturned() = runTest { + Mockito.doThrow(RuntimeException()) + .`when`(manager) + .queryAllRecordTypesInfo(Matchers.any(), Matchers.any()) + + val loadedContributingApps = usecase.invoke(HealthDataCategory.ACTIVITY) + + Truth.assertThat(loadedContributingApps).isEmpty() + } + + private fun prepareAnswer( + recordTypeInfo: Map<Record, RecordTypeInfoResponse> + ): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[1] as OutcomeReceiver<Map<Record, RecordTypeInfoResponse>, *> + receiver.onResult(recordTypeInfo) + null + } + return answer + } + + private fun getStepsCadence(samples: List<Double>): StepsCadenceRecord { + return StepsCadenceRecord.Builder( + getMetaData(), + NOW, + NOW.plusSeconds(samples.size.toLong() + 1), + samples.map { rate -> + StepsCadenceRecord.StepsCadenceRecordSample(rate, NOW.plusSeconds(1)) + }) + .build() + } + + private fun getRecord( + type: Int = ExerciseSessionType.EXERCISE_SESSION_TYPE_BIKING, + title: String? = null, + note: String? = null, + laps: List<ExerciseLap> = emptyList(), + segments: List<ExerciseSegment> = emptyList() + ): ExerciseSessionRecord { + return ExerciseSessionRecord.Builder(getMetaData(), NOW, NOW.plusSeconds(1000), type) + .setNotes(note) + .setLaps(laps) + .setTitle(title) + .setSegments(segments) + .build() + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadPriorityListUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadPriorityListUseCaseTest.kt new file mode 100644 index 00000000..75dbf0bd --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/LoadPriorityListUseCaseTest.kt @@ -0,0 +1,81 @@ +package com.android.healthconnect.controller.tests.permissiontypes.api + +import android.content.Context +import android.health.connect.FetchDataOriginsPriorityOrderResponse +import android.health.connect.HealthConnectManager +import android.health.connect.HealthDataCategory +import android.os.OutcomeReceiver +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.permissiontypes.api.LoadPriorityListUseCase +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.tests.utils.CoroutineTestRule +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.getDataOrigin +import com.google.common.truth.Truth +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Matchers +import org.mockito.Mockito +import org.mockito.invocation.InvocationOnMock + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class LoadPriorityListUseCaseTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private val manager: HealthConnectManager = Mockito.mock(HealthConnectManager::class.java) + @Inject lateinit var appInfoReader: AppInfoReader + private lateinit var usecase: LoadPriorityListUseCase + private lateinit var context: Context + + @Before + fun setup() { + hiltRule.inject() + context = InstrumentationRegistry.getInstrumentation().context + usecase = LoadPriorityListUseCase(manager, appInfoReader, Dispatchers.Main) + } + + @Test + fun loadPriorityList_listOfAppsInPriorityListReturnedCorrectly() = runTest { + val dataOriginsPriorityOrderResponse = + FetchDataOriginsPriorityOrderResponse( + mutableListOf( + getDataOrigin(TEST_APP_PACKAGE_NAME), getDataOrigin(TEST_APP_PACKAGE_NAME_2))) + + Mockito.doAnswer(prepareAnswer(dataOriginsPriorityOrderResponse)) + .`when`(manager) + .fetchDataOriginsPriorityOrder( + Matchers.eq(HealthDataCategory.ACTIVITY), Matchers.any(), Matchers.any()) + + val loadedAppsPriorityList = usecase.execute(HealthDataCategory.ACTIVITY) + + Truth.assertThat(loadedAppsPriorityList.size).isEqualTo(2) + + Truth.assertThat(loadedAppsPriorityList) + .contains(appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME)) + + Truth.assertThat(loadedAppsPriorityList) + .contains(appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME_2)) + } + + private fun prepareAnswer( + fetchDataOriginsPriorityOrderResponse: FetchDataOriginsPriorityOrderResponse + ): (InvocationOnMock) -> Nothing? { + val answer = { args: InvocationOnMock -> + val receiver = + args.arguments[2] as OutcomeReceiver<FetchDataOriginsPriorityOrderResponse, *> + receiver.onResult(fetchDataOriginsPriorityOrderResponse) + null + } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/UpdatePriorityListUseCaseTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/UpdatePriorityListUseCaseTest.kt new file mode 100644 index 00000000..d34baa24 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/api/UpdatePriorityListUseCaseTest.kt @@ -0,0 +1,76 @@ +package com.android.healthconnect.controller.tests.permissiontypes.api + +import android.content.Context +import android.health.connect.HealthConnectManager +import android.health.connect.HealthDataCategory +import android.health.connect.UpdateDataOriginPriorityOrderRequest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.permissiontypes.api.UpdatePriorityListUseCase +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.tests.utils.CoroutineTestRule +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.getDataOrigin +import com.google.common.truth.Truth +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.invocation.InvocationOnMock + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class UpdatePriorityListUseCaseTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private val manager: HealthConnectManager = Mockito.mock(HealthConnectManager::class.java) + private lateinit var useCase: UpdatePriorityListUseCase + @Inject lateinit var appInfoReader: AppInfoReader + private lateinit var context: Context + @Captor lateinit var filtersCaptor: ArgumentCaptor<UpdateDataOriginPriorityOrderRequest> + + @Before + fun setup() { + hiltRule.inject() + MockitoAnnotations.initMocks(this) + context = InstrumentationRegistry.getInstrumentation().context + useCase = UpdatePriorityListUseCase(manager, Dispatchers.Main) + } + + @Test + fun invoke_updatePriorityList_callsHealthManager() = runTest { + Mockito.doAnswer(prepareAnswer()) + .`when`(manager) + .updateDataOriginPriorityOrder( + Mockito.any(UpdateDataOriginPriorityOrderRequest::class.java), + Mockito.any(), + Mockito.any()) + + val updatedPriorityList = listOf(TEST_APP_PACKAGE_NAME, TEST_APP_PACKAGE_NAME_2) + + useCase.invoke(updatedPriorityList, HealthDataCategory.ACTIVITY) + + Mockito.verify(manager, Mockito.times(1)) + .updateDataOriginPriorityOrder(filtersCaptor.capture(), Mockito.any(), Mockito.any()) + Truth.assertThat(filtersCaptor.value.dataCategory).isEqualTo(HealthDataCategory.ACTIVITY) + Truth.assertThat(filtersCaptor.value.dataOriginInOrder) + .containsExactlyElementsIn( + listOf( + getDataOrigin(TEST_APP_PACKAGE_NAME), getDataOrigin(TEST_APP_PACKAGE_NAME_2))) + } + + private fun prepareAnswer(): (InvocationOnMock) -> Nothing? { + val answer = { _: InvocationOnMock -> null } + return answer + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/prioritylist/PriorityListAdapterTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/prioritylist/PriorityListAdapterTest.kt new file mode 100644 index 00000000..cd111ead --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/permissiontypes/prioritylist/PriorityListAdapterTest.kt @@ -0,0 +1,63 @@ +package com.android.healthconnect.controller.tests.permissiontypes.prioritylist + +import com.android.healthconnect.controller.permissiontypes.HealthPermissionTypesViewModel +import com.android.healthconnect.controller.permissiontypes.prioritylist.PriorityListAdapter +import com.android.healthconnect.controller.shared.app.AppInfoReader +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_2 +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME_3 +import com.google.common.truth.Truth +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock + +@ExperimentalCoroutinesApi +@HiltAndroidTest +class PriorityListAdapterTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + + private val viewModel = mock(HealthPermissionTypesViewModel::class.java) + private lateinit var priorityListAdapter: PriorityListAdapter + + @Inject lateinit var appInfoReader: AppInfoReader + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun getPackageNameList_packagesAreReturnedCorrectly() = runTest { + priorityListAdapter = + PriorityListAdapter( + listOf( + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME), + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME_2), + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME_3)), + viewModel) + Truth.assertThat(priorityListAdapter.getPackageNameList()) + .containsExactly( + TEST_APP_PACKAGE_NAME, TEST_APP_PACKAGE_NAME_2, TEST_APP_PACKAGE_NAME_3) + } + + @Test + fun onItemMove_itemsAreReArrangedCorrectly() = runTest { + priorityListAdapter = + PriorityListAdapter( + listOf( + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME), + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME_2), + appInfoReader.getAppMetadata(TEST_APP_PACKAGE_NAME_3)), + viewModel) + priorityListAdapter.onItemMove(2, 1) + Truth.assertThat(priorityListAdapter.getPackageNameList()) + .containsExactly( + TEST_APP_PACKAGE_NAME, TEST_APP_PACKAGE_NAME_3, TEST_APP_PACKAGE_NAME_2) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/selectabledeletion/DeletionTypeTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/selectabledeletion/DeletionTypeTest.kt new file mode 100644 index 00000000..47001787 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/selectabledeletion/DeletionTypeTest.kt @@ -0,0 +1,101 @@ +package com.android.healthconnect.controller.tests.selectabledeletion + +import android.os.Parcel +import com.android.healthconnect.controller.permissions.data.HealthPermissionType +import com.android.healthconnect.controller.selectabledeletion.DeletionType +import com.android.healthconnect.controller.shared.DataType +import com.android.healthconnect.controller.tests.utils.TEST_APP_NAME +import com.android.healthconnect.controller.tests.utils.TEST_APP_PACKAGE_NAME +import junit.framework.Assert.assertTrue +import org.junit.Test + +class DeletionTypeTest { + + @Test + fun deletionTypeHealthPermissionTypeData_isParcelable() { + val deletionType = + DeletionType.DeletionTypeHealthPermissionTypes( + listOf( + HealthPermissionType.ACTIVE_CALORIES_BURNED, + HealthPermissionType.BLOOD_GLUCOSE)) + + val parcel = Parcel.obtain() + deletionType.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val recreatedDeletionType = + DeletionType.DeletionTypeHealthPermissionTypes.CREATOR.createFromParcel(parcel) + + assertTrue( + recreatedDeletionType.healthPermissionTypes == deletionType.healthPermissionTypes) + assertTrue(recreatedDeletionType.hasPermissionTypes) + assertTrue(!recreatedDeletionType.hasAppData) + assertTrue(!recreatedDeletionType.hasEntryIds) + } + + @Test + fun deletionTypeAppData_isParcelable() { + val deletionType = + DeletionType.DeletionTypeAppData( + packageName = TEST_APP_PACKAGE_NAME, appName = TEST_APP_NAME) + + val parcel = Parcel.obtain() + deletionType.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val recreatedDeletionType = + DeletionType.DeletionTypeAppData.CREATOR.createFromParcel(parcel) + + assertTrue(recreatedDeletionType.appName == deletionType.appName) + assertTrue(recreatedDeletionType.packageName == deletionType.packageName) + assertTrue(!recreatedDeletionType.hasPermissionTypes) + assertTrue(recreatedDeletionType.hasAppData) + assertTrue(!recreatedDeletionType.hasEntryIds) + } + + @Test + fun deletionTypeEntries_isParcelable() { + val deletionType = + DeletionType.DeletionTypeEntries( + listOf("dataEntryId1", "dataEntryId2"), DataType.ACTIVE_CALORIES_BURNED) + + val parcel = Parcel.obtain() + deletionType.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val recreatedDeletionType = + DeletionType.DeletionTypeEntries.CREATOR.createFromParcel(parcel) + + assertTrue(recreatedDeletionType.dataType == deletionType.dataType) + assertTrue(recreatedDeletionType.ids == deletionType.ids) + assertTrue(!recreatedDeletionType.hasPermissionTypes) + assertTrue(!recreatedDeletionType.hasAppData) + assertTrue(recreatedDeletionType.hasEntryIds) + } + + @Test + fun deletionTypeHealthPermissionTypesFromApp_isParcelable() { + val deletionType = + DeletionType.DeletionTypeHealthPermissionTypesFromApp( + listOf( + HealthPermissionType.ACTIVE_CALORIES_BURNED, + HealthPermissionType.BLOOD_GLUCOSE), + packageName = TEST_APP_PACKAGE_NAME, + appName = TEST_APP_NAME) + + val parcel = Parcel.obtain() + deletionType.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val recreatedDeletionType = + DeletionType.DeletionTypeHealthPermissionTypesFromApp.CREATOR.createFromParcel(parcel) + + assertTrue(recreatedDeletionType.appName == deletionType.appName) + assertTrue(recreatedDeletionType.packageName == deletionType.packageName) + assertTrue( + recreatedDeletionType.healthPermissionTypes == deletionType.healthPermissionTypes) + assertTrue(recreatedDeletionType.hasPermissionTypes) + assertTrue(recreatedDeletionType.hasAppData) + assertTrue(!deletionType.hasEntryIds) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/ApiExtensions.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/ApiExtensions.kt new file mode 100644 index 00000000..3f60e2d6 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/ApiExtensions.kt @@ -0,0 +1,22 @@ +package com.android.healthconnect.controller.tests.utils + +import android.health.connect.ReadRecordsRequestUsingFilters +import android.health.connect.TimeInstantRangeFilter +import android.health.connect.datatypes.Record + +fun ReadRecordsRequestUsingFilters<Record>.fromDataSource(packageName: String): Boolean { + return this.dataOrigins.any { dataOrigin -> dataOrigin.packageName == packageName } +} + +fun ReadRecordsRequestUsingFilters<Record>.fromTimeRange( + sourceTimeFilter: TimeInstantRangeFilter +): Boolean { + val thisTimeRangeFilter = this.timeRangeFilter + if (thisTimeRangeFilter !is TimeInstantRangeFilter) return false + return thisTimeRangeFilter.startTime == sourceTimeFilter.startTime && + thisTimeRangeFilter.endTime == sourceTimeFilter.endTime +} + +fun ReadRecordsRequestUsingFilters<Record>.forDataType(dataType: Class<out Record>): Boolean { + return this.recordType == dataType +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/AppStoreUtilTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/AppStoreUtilTest.kt new file mode 100644 index 00000000..d77d924b --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/AppStoreUtilTest.kt @@ -0,0 +1,44 @@ +package com.android.healthconnect.controller.tests.utils + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import com.android.healthconnect.controller.utils.AppStoreUtils +import com.android.healthconnect.controller.utils.DeviceInfoUtils +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class AppStoreUtilTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + @Inject lateinit var appStoreUtils: AppStoreUtils + @Inject lateinit var deviceInfoUtils: DeviceInfoUtils + private lateinit var context: Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().context + hiltRule.inject() + } + + @Test + fun getAppStoreLink_validPackage_returnsCorrectIntent() { + // skip the test on AOSP devices + if (!deviceInfoUtils.isPlayStoreAvailable(context)) { + return + } + + val intent = appStoreUtils.getAppStoreLink(TEST_APP_PACKAGE_NAME) + + assertThat(intent).isNotNull() + assertThat(intent!!.action).isEqualTo("android.intent.action.SHOW_APP_INFO") + assertThat(intent.extras?.get("android.intent.extra.PACKAGE_NAME")) + .isEqualTo(TEST_APP_PACKAGE_NAME) + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/LocalDateTimeFormatterTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/LocalDateTimeFormatterTest.kt new file mode 100644 index 00000000..bcb66f32 --- /dev/null +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/LocalDateTimeFormatterTest.kt @@ -0,0 +1,165 @@ +package com.android.healthconnect.controller.tests.utils + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry.* +import com.android.healthconnect.controller.utils.LocalDateTimeFormatter +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Locale +import java.util.TimeZone +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class LocalDateTimeFormatterTest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + private lateinit var formatter: LocalDateTimeFormatter + + private lateinit var context: Context + + private var previousDefaultTimeZone: TimeZone? = null + private var previousLocale: Locale? = null + + private val time = Instant.parse("2022-10-20T14:06:05.432Z") + + @Before + fun setup() { + hiltRule.inject() + + context = getInstrumentation().context + previousDefaultTimeZone = TimeZone.getDefault() + previousLocale = context.resources.configuration.locale + + // set default local + context.setLocale(Locale.UK) + + // set time zone + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + formatter = LocalDateTimeFormatter(context) + } + + fun tearDown() { + TimeZone.setDefault(previousDefaultTimeZone) + previousLocale?.let { locale -> context.setLocale(locale) } + } + + @Test + fun formatTime_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatTime(time)).isEqualTo("14:06") + } + + @Test + fun formatTime_usLocale() { + context.setLocale(Locale.US) + assertThat(formatter.formatTime(time)).isEqualTo("2:06 PM") + } + + @Test + fun formatLongDate_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatLongDate(time)).isEqualTo("20 October 2022") + } + + @Test + fun formatLongDate_usLocale() { + context.setLocale(Locale.US) + assertThat(formatter.formatLongDate(time)).isEqualTo("October 20, 2022") + } + + @Test + fun formatShortDate_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatShortDate(time)).isEqualTo("20 October") + } + + @Test + fun formatShortDate_usLocale() { + context.setLocale(Locale.US) + assertThat(formatter.formatShortDate(time)).isEqualTo("October 20") + } + + @Test + fun formatTimeRange_ukLocale() { + context.setLocale(Locale.UK) + val end = time.plus(1, ChronoUnit.HOURS) + assertThat(formatter.formatTimeRange(time, end)).isEqualTo("14:06 - 15:06") + } + + @Test + fun formatTimeRange_usLocale() { + context.setLocale(Locale.US) + val end = time.plus(1, ChronoUnit.HOURS) + assertThat(formatter.formatTimeRange(time, end)).isEqualTo("2:06 PM - 3:06 PM") + } + + @Test + fun formatTimeRangeA11y_ukLocale() { + context.setLocale(Locale.UK) + val end = time.plus(1, ChronoUnit.HOURS) + assertThat(formatter.formatTimeRangeA11y(time, end)).isEqualTo("from 14:06 to 15:06") + } + + @Test + fun formatTimeRangeA11y_usLocale() { + context.setLocale(Locale.US) + val end = time.plus(1, ChronoUnit.HOURS) + assertThat(formatter.formatTimeRangeA11y(time, end)).isEqualTo("from 2:06 PM to 3:06 PM") + } + + @Test + fun formatDateRangeWithYear_ukLocale() { + context.setLocale(Locale.UK) + val end = time.plus(10, ChronoUnit.DAYS) + assertThat(formatter.formatDateRangeWithYear(time, end)).isEqualTo("20–30 Oct 2022") + } + + @Test + fun formatDateRangeWithoutYear_ukLocale() { + context.setLocale(Locale.UK) + val end = time.plus(10, ChronoUnit.DAYS) + assertThat(formatter.formatDateRangeWithoutYear(time, end)).isEqualTo("20–30 Oct") + } + + @Test + fun formatMonthWithYear_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatMonthWithYear(time)).isEqualTo("October 2022") + } + + @Test + fun formatMonthWithoutYear_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatMonthWithoutYear(time)).isEqualTo("October") + } + + @Test + fun formatWeekdayDateWithYear_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatWeekdayDateWithYear(time)).isEqualTo("Thu, 20 Oct 2022") + } + + @Test + fun formatWeekdayDateWithYear_usLocale() { + context.setLocale(Locale.US) + assertThat(formatter.formatWeekdayDateWithYear(time)).isEqualTo("Thu, Oct 20, 2022") + } + + @Test + fun formatWeekdayDateWithoutYear_ukLocale() { + context.setLocale(Locale.UK) + assertThat(formatter.formatWeekdayDateWithoutYear(time)).isEqualTo("Thu, 20 Oct") + } + + @Test + fun formatWeekdayDateWithoutYear_usLocale() { + context.setLocale(Locale.US) + assertThat(formatter.formatWeekdayDateWithoutYear(time)).isEqualTo("Thu, Oct 20") + } +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestConstants.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestConstants.kt index 5c9532d6..7ee2fa60 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestConstants.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestConstants.kt @@ -18,13 +18,26 @@ package com.android.healthconnect.controller.tests.utils import android.health.connect.datatypes.BasalMetabolicRateRecord import android.health.connect.datatypes.DataOrigin import android.health.connect.datatypes.Device +import android.health.connect.datatypes.DistanceRecord import android.health.connect.datatypes.HeartRateRecord import android.health.connect.datatypes.Metadata +import android.health.connect.datatypes.Record +import android.health.connect.datatypes.SleepSessionRecord import android.health.connect.datatypes.StepsRecord +import android.health.connect.datatypes.TotalCaloriesBurnedRecord +import android.health.connect.datatypes.units.Energy +import android.health.connect.datatypes.units.Length import android.health.connect.datatypes.units.Power import com.android.healthconnect.controller.dataentries.units.PowerConverter +import com.android.healthconnect.controller.permissions.data.HealthPermissionType import com.android.healthconnect.controller.shared.app.AppMetadata +import com.android.healthconnect.controller.utils.randomInstant +import com.android.healthconnect.controller.utils.toInstant +import com.android.healthconnect.controller.utils.toLocalDateTime +import com.google.common.truth.Truth.assertThat import java.time.Instant +import java.time.LocalDate +import kotlin.random.Random val NOW: Instant = Instant.parse("2022-10-20T07:06:05.432Z") val MIDNIGHT: Instant = Instant.parse("2022-10-20T00:00:00.000Z") @@ -47,6 +60,40 @@ fun getBasalMetabolicRateRecord(calories: Long): BasalMetabolicRateRecord { return BasalMetabolicRateRecord.Builder(getMetaData(), NOW, Power.fromWatts(watts)).build() } +fun getDistanceRecord(distance: Length, time: Instant = NOW): DistanceRecord { + return DistanceRecord.Builder(getMetaData(), time, time.plusSeconds(2), distance).build() +} + +fun getTotalCaloriesBurnedRecord(calories: Energy, time: Instant = NOW): TotalCaloriesBurnedRecord { + return TotalCaloriesBurnedRecord.Builder(getMetaData(), time, time.plusSeconds(2), calories) + .build() +} + +fun getSleepSessionRecord(startTime: Instant = NOW): SleepSessionRecord { + val endTime = startTime.toLocalDateTime().plusHours(8).toInstant() + return SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build() +} + +fun getSleepSessionRecord(startTime: Instant, endTime: Instant): SleepSessionRecord { + return SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build() +} + +fun getRandomRecord(healthPermissionType: HealthPermissionType, date: LocalDate): Record { + return when (healthPermissionType) { + HealthPermissionType.STEPS -> getStepsRecord(Random.nextLong(0, 5000), date.randomInstant()) + HealthPermissionType.DISTANCE -> + getDistanceRecord( + Length.fromMeters(Random.nextDouble(0.0, 5000.0)), date.randomInstant()) + HealthPermissionType.TOTAL_CALORIES_BURNED -> + getTotalCaloriesBurnedRecord( + Energy.fromCalories(Random.nextDouble(1500.0, 5000.0)), date.randomInstant()) + HealthPermissionType.SLEEP -> getSleepSessionRecord(date.randomInstant()) + else -> + throw IllegalArgumentException( + "HealthPermissionType $healthPermissionType not supported") + } +} + fun getMetaData(): Metadata { return getMetaData(TEST_APP_PACKAGE_NAME) } @@ -66,6 +113,30 @@ fun getMetaData(packageName: String): Metadata { fun getDataOrigin(packageName: String): DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() +fun getSleepSessionRecords(inputDates: List<Pair<Instant, Instant>>): List<SleepSessionRecord> { + val result = arrayListOf<SleepSessionRecord>() + inputDates.forEach { (startTime, endTime) -> + result.add(SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build()) + } + + return result +} + +fun verifySleepSessionListsEqual(actual: List<Record>, expected: List<SleepSessionRecord>) { + assertThat(actual.size).isEqualTo(expected.size) + for ((index, element) in actual.withIndex()) { + assertThat(element is SleepSessionRecord).isTrue() + val expectedElement = expected[index] + val actualElement = element as SleepSessionRecord + + assertThat(actualElement.startTime).isEqualTo(expectedElement.startTime) + assertThat(actualElement.endTime).isEqualTo(expectedElement.endTime) + assertThat(actualElement.notes).isEqualTo(expectedElement.notes) + assertThat(actualElement.title).isEqualTo(expectedElement.title) + assertThat(actualElement.stages).isEqualTo(expectedElement.stages) + } +} + // region apps const val TEST_APP_PACKAGE_NAME = "android.healthconnect.controller.test.app" @@ -81,6 +152,6 @@ val TEST_APP = val TEST_APP_2 = AppMetadata(packageName = TEST_APP_PACKAGE_NAME_2, appName = TEST_APP_NAME_2, icon = null) val TEST_APP_3 = - AppMetadata(packageName = TEST_APP_PACKAGE_NAME_2, appName = TEST_APP_NAME_3, icon = null) + AppMetadata(packageName = TEST_APP_PACKAGE_NAME_3, appName = TEST_APP_NAME_3, icon = null) // endregion diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestTimeSource.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestTimeSource.kt index c5ded855..ee547d2b 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestTimeSource.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/TestTimeSource.kt @@ -3,9 +3,11 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at + * * ``` * http://www.apache.org/licenses/LICENSE-2.0 * ``` + * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under @@ -27,18 +29,26 @@ import javax.inject.Singleton /** Time source for testing purposes. */ object TestTimeSource : TimeSource { - override fun currentTimeMillis(): Long = NOW.toEpochMilli() + private var localNow: Instant = NOW + + override fun currentTimeMillis(): Long = localNow.toEpochMilli() override fun deviceZoneOffset(): ZoneId = UTC override fun currentLocalDateTime(): LocalDateTime = Instant.ofEpochMilli(currentTimeMillis()).atZone(deviceZoneOffset()).toLocalDateTime() + + fun setNow(instant: Instant) { + localNow = instant + } + + fun reset() { + localNow = NOW + } } @Module @TestInstallIn(components = [SingletonComponent::class], replaces = [SystemTimeSourceModule::class]) object TestTimeSourceModule { - @Provides - @Singleton - fun providesTestTimeSource() : TimeSource = TestTimeSource -}
\ No newline at end of file + @Provides @Singleton fun providesTestTimeSource(): TimeSource = TestTimeSource +} diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/TimeExtensionsTest.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/TimeExtensionsTest.kt index b64f3ad0..02692c7f 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/utils/TimeExtensionsTest.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/TimeExtensionsTest.kt @@ -21,13 +21,16 @@ import com.android.healthconnect.controller.utils.isAtLeastOneDayAfter import com.android.healthconnect.controller.utils.isOnDayAfter import com.android.healthconnect.controller.utils.isOnDayBefore import com.android.healthconnect.controller.utils.isOnSameDay +import com.android.healthconnect.controller.utils.randomInstant import com.android.healthconnect.controller.utils.toInstant import com.android.healthconnect.controller.utils.toInstantAtStartOfDay import com.android.healthconnect.controller.utils.toLocalDate +import com.android.healthconnect.controller.utils.toLocalDateTime import com.android.healthconnect.controller.utils.toLocalTime import com.google.common.truth.Truth.assertThat import java.time.Instant import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId import java.util.TimeZone @@ -182,4 +185,36 @@ class TimeExtensionsTest { val expectedInstant = Instant.parse("2021-10-01T03:00:00Z") assertThat(testLocalDate.toInstantAtStartOfDay()).isEqualTo(expectedInstant) } + + @Test + fun instantToLocalDateTime_returnsLocalizedDateTime() { + // UTC + 8 + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Australia/Perth"))) + + val testInstant = Instant.parse("2023-05-18T20:00:00Z") + val expectedLocalDateTime = LocalDateTime.of(2023, 5, 19, 4, 0) + + assertThat(testInstant.toLocalDateTime()).isEqualTo(expectedLocalDateTime) + } + + @Test + fun localDateRandomInstant_returnsInstantOnTheLocalDate() { + // UTC + 5:30 + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Kolkata"))) + + val localDate = LocalDate.of(2023, 7, 18) + val randomInstant = localDate.randomInstant() + + assertThat(randomInstant.isOnSameDay(localDate.toInstantAtStartOfDay())).isTrue() + } + + @Test + fun localDateTimeToInstant_returnsCorrectInstant() { + // UTC - 3 + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/Sao_Paulo"))) + + val testLocalDateTime = LocalDateTime.of(2021, 10, 1, 18, 0) + val expectedInstant = Instant.parse("2021-10-01T21:00:00Z") + assertThat(testLocalDateTime.toInstant()).isEqualTo(expectedInstant) + } } diff --git a/apk/tests/src/com/android/healthconnect/controller/tests/utils/di/FakeComponents.kt b/apk/tests/src/com/android/healthconnect/controller/tests/utils/di/FakeComponents.kt index a7187858..d0887908 100644 --- a/apk/tests/src/com/android/healthconnect/controller/tests/utils/di/FakeComponents.kt +++ b/apk/tests/src/com/android/healthconnect/controller/tests/utils/di/FakeComponents.kt @@ -18,26 +18,33 @@ package com.android.healthconnect.controller.tests.utils.di import android.health.connect.HealthDataCategory import android.health.connect.accesslog.AccessLog import android.health.connect.datatypes.Record +import com.android.healthconnect.controller.data.access.AppAccessState +import com.android.healthconnect.controller.data.access.ILoadAccessUseCase +import com.android.healthconnect.controller.data.access.ILoadPermissionTypeContributorAppsUseCase import com.android.healthconnect.controller.data.entries.FormattedEntry import com.android.healthconnect.controller.data.entries.api.ILoadDataAggregationsUseCase import com.android.healthconnect.controller.data.entries.api.ILoadDataEntriesUseCase import com.android.healthconnect.controller.data.entries.api.ILoadMenstruationDataUseCase -import com.android.healthconnect.controller.data.entries.api.ILoadSleepDataUseCase import com.android.healthconnect.controller.data.entries.api.LoadAggregationInput import com.android.healthconnect.controller.data.entries.api.LoadDataEntriesInput import com.android.healthconnect.controller.data.entries.api.LoadMenstruationDataInput import com.android.healthconnect.controller.datasources.AggregationCardInfo +import com.android.healthconnect.controller.datasources.api.ILoadLastDateWithPriorityDataUseCase import com.android.healthconnect.controller.datasources.api.ILoadMostRecentAggregationsUseCase import com.android.healthconnect.controller.datasources.api.ILoadPotentialPriorityListUseCase +import com.android.healthconnect.controller.datasources.api.ILoadPriorityEntriesUseCase +import com.android.healthconnect.controller.datasources.api.ISleepSessionHelper import com.android.healthconnect.controller.datasources.api.IUpdatePriorityListUseCase +import com.android.healthconnect.controller.permissions.api.IGetGrantedHealthPermissionsUseCase import com.android.healthconnect.controller.permissions.connectedapps.ILoadHealthPermissionApps +import com.android.healthconnect.controller.permissions.data.HealthPermissionType import com.android.healthconnect.controller.permissiontypes.api.ILoadPriorityListUseCase import com.android.healthconnect.controller.recentaccess.ILoadRecentAccessUseCase import com.android.healthconnect.controller.shared.HealthDataCategoryInt import com.android.healthconnect.controller.shared.app.AppMetadata import com.android.healthconnect.controller.shared.app.ConnectedAppMetadata import com.android.healthconnect.controller.shared.usecase.UseCaseResults -import com.android.healthconnect.controller.utils.toLocalDate +import java.time.Instant import java.time.LocalDate class FakeRecentAccessUseCase : ILoadRecentAccessUseCase { @@ -65,18 +72,18 @@ class FakeHealthPermissionAppsUseCase : ILoadHealthPermissionApps { } class FakeLoadDataEntriesUseCase : ILoadDataEntriesUseCase { - private var list: List<FormattedEntry> = emptyList() + private var formattedList = listOf<FormattedEntry>() fun updateList(list: List<FormattedEntry>) { - this.list = list + formattedList = list } override suspend fun invoke(input: LoadDataEntriesInput): UseCaseResults<List<FormattedEntry>> { - return UseCaseResults.Success(list) + return UseCaseResults.Success(formattedList) } override suspend fun execute(input: LoadDataEntriesInput): List<FormattedEntry> { - return list + return formattedList } } @@ -103,8 +110,9 @@ class FakeLoadDataAggregationsUseCase : ILoadDataAggregationsUseCase { FormattedEntry.FormattedAggregation("100 steps", "100 steps", "Test App") private var aggregations: List<FormattedEntry.FormattedAggregation> = listOf(aggregation) - private var invocationCount = 0 - private var shouldReturnFailed = false + var invocationCount = 0 + private var forceFail = false + private var exceptionMessage = "" fun updateAggregation(aggregation: FormattedEntry.FormattedAggregation) { this.aggregations = listOf(aggregation) @@ -115,8 +123,9 @@ class FakeLoadDataAggregationsUseCase : ILoadDataAggregationsUseCase { this.aggregations = aggregations } - fun updateErrorResponse() { - this.shouldReturnFailed = true + fun setFailure(exceptionMessage: String) { + forceFail = true + this.exceptionMessage = exceptionMessage } override suspend fun invoke( @@ -127,8 +136,8 @@ class FakeLoadDataAggregationsUseCase : ILoadDataAggregationsUseCase { IllegalStateException( "AggregationResponsesSize = ${this.aggregations.size}, " + "invocationCount = $invocationCount. Please update aggregation responses before invoking.")) - } else if (shouldReturnFailed) { - UseCaseResults.Failed(IllegalStateException("Custom failure")) + } else if (forceFail) { + UseCaseResults.Failed(IllegalStateException(exceptionMessage)) } else { val result = UseCaseResults.Success(aggregations[invocationCount]) invocationCount += 1 @@ -143,7 +152,8 @@ class FakeLoadDataAggregationsUseCase : ILoadDataAggregationsUseCase { fun reset() { this.invocationCount = 0 this.aggregations = listOf(aggregation) - this.shouldReturnFailed = false + exceptionMessage = "" + forceFail = false } } @@ -166,6 +176,66 @@ class FakeLoadMostRecentAggregationsUseCase : ILoadMostRecentAggregationsUseCase } } +class FakeSleepSessionHelper : ISleepSessionHelper { + + private var forceFail = false + private var exceptionMessage = "" + private var datePair = Pair(Instant.EPOCH, Instant.EPOCH) + + fun setDatePair(minDate: Instant, maxDate: Instant) { + datePair = Pair(minDate, maxDate) + } + + fun setFailure(exceptionMessage: String) { + forceFail = true + this.exceptionMessage = exceptionMessage + } + + override suspend fun clusterSleepSessions( + lastDateWithData: LocalDate + ): UseCaseResults<Pair<Instant, Instant>> { + return if (forceFail) UseCaseResults.Failed(Exception(this.exceptionMessage)) + else UseCaseResults.Success(datePair) + } + + fun reset() { + datePair = Pair(Instant.EPOCH, Instant.EPOCH) + exceptionMessage = "" + forceFail = false + } +} + +class FakeLoadPriorityEntriesUseCase : ILoadPriorityEntriesUseCase { + + private var priorityEntries = mutableMapOf<LocalDate, List<Record>>() + private var forceFail = false + private var exceptionMessage = "" + + override suspend fun invoke( + healthPermissionType: HealthPermissionType, + localDate: LocalDate + ): UseCaseResults<List<Record>> { + return if (forceFail) UseCaseResults.Failed(Exception(this.exceptionMessage)) + else UseCaseResults.Success(priorityEntries.getOrDefault(localDate, listOf())) + } + + fun setEntriesList(localDate: LocalDate, list: List<Record>) { + + priorityEntries[localDate] = list + } + + fun setFailure(exceptionMessage: String) { + forceFail = true + this.exceptionMessage = exceptionMessage + } + + fun reset() { + priorityEntries.clear() + exceptionMessage = "" + forceFail = false + } +} + class FakeLoadPotentialPriorityListUseCase : ILoadPotentialPriorityListUseCase { private var potentialPriorityList = listOf<AppMetadata>() @@ -188,11 +258,14 @@ class FakeLoadPotentialPriorityListUseCase : ILoadPotentialPriorityListUseCase { class FakeLoadPriorityListUseCase : ILoadPriorityListUseCase { private var priorityList = listOf<AppMetadata>() + private var forceFail = false + private var exceptionMessage = "" override suspend fun invoke( input: @HealthDataCategoryInt Int ): UseCaseResults<List<AppMetadata>> { - return UseCaseResults.Success(priorityList) + return if (forceFail) UseCaseResults.Failed(Exception(this.exceptionMessage)) + else UseCaseResults.Success(priorityList) } override suspend fun execute(input: Int): List<AppMetadata> { @@ -203,8 +276,15 @@ class FakeLoadPriorityListUseCase : ILoadPriorityListUseCase { this.priorityList = priorityList } + fun setFailure(exceptionMessage: String) { + forceFail = true + this.exceptionMessage = exceptionMessage + } + fun reset() { this.priorityList = listOf() + exceptionMessage = "" + forceFail = false } } @@ -224,24 +304,89 @@ class FakeUpdatePriorityListUseCase : IUpdatePriorityListUseCase { } } -class FakeLoadSleepDataUseCase : ILoadSleepDataUseCase { +class FakeLoadAccessUseCase : ILoadAccessUseCase { - private var sleepDataMap: MutableMap<LocalDate, List<Record>> = mutableMapOf() + private var appDataMap: Map<AppAccessState, List<AppMetadata>> = mutableMapOf() - fun updateSleepData(date: LocalDate, recordsList: List<Record>) { - sleepDataMap[date] = recordsList + override suspend fun invoke( + permissionType: HealthPermissionType + ): UseCaseResults<Map<AppAccessState, List<AppMetadata>>> { + return UseCaseResults.Success(appDataMap) + } + + fun updateMap(map: Map<AppAccessState, List<AppMetadata>>) { + appDataMap = map + } + + fun reset() { + this.appDataMap = mutableMapOf() + } +} + +class FakeLoadPermissionTypeContributorAppsUseCase : ILoadPermissionTypeContributorAppsUseCase { + + private var contributorApps: List<AppMetadata> = listOf() + + override suspend fun invoke(permissionType: HealthPermissionType): List<AppMetadata> { + return contributorApps + } + + fun updateList(list: List<AppMetadata>) { + contributorApps = list + } + + fun reset() { + this.contributorApps = listOf() } +} + +class FakeGetGrantedHealthPermissionsUseCase : IGetGrantedHealthPermissionsUseCase { + + private var permissionsPerApp: MutableMap<String, List<String>> = mutableMapOf() + + override fun invoke(packageName: String): List<String> { + return permissionsPerApp.getOrDefault(packageName, listOf()) + } + + fun updateData(packageName: String, permissions: List<String>) { + permissionsPerApp[packageName] = permissions + } + + fun reset() { + this.permissionsPerApp = mutableMapOf() + } +} + +class FakeLoadLastDateWithPriorityDataUseCase : ILoadLastDateWithPriorityDataUseCase { - override suspend fun invoke(input: LoadDataEntriesInput): UseCaseResults<List<Record>> { - val result = sleepDataMap.getOrDefault(input.displayedStartTime.toLocalDate(), listOf()) - return UseCaseResults.Success(result) + private var lastDateWithPriorityDataMap = mutableMapOf<HealthPermissionType, LocalDate?>() + private var forceFail = false + private var exceptionMessage = "" + + fun setLastDateWithPriorityDataForHealthPermissionType( + healthPermissionType: HealthPermissionType, + localDate: LocalDate? + ) { + lastDateWithPriorityDataMap[healthPermissionType] = localDate + } + + fun setFailure(exceptionMessage: String) { + forceFail = true + this.exceptionMessage = exceptionMessage } - override suspend fun execute(input: LoadDataEntriesInput): List<Record> { - return sleepDataMap.getOrDefault(input.displayedStartTime.toLocalDate(), listOf()) + override suspend fun invoke( + healthPermissionType: HealthPermissionType + ): UseCaseResults<LocalDate?> { + if (forceFail) return UseCaseResults.Failed(Exception(this.exceptionMessage)) + return if (lastDateWithPriorityDataMap.containsKey(healthPermissionType)) + UseCaseResults.Success(lastDateWithPriorityDataMap[healthPermissionType]) + else UseCaseResults.Success(null) } fun reset() { - this.sleepDataMap = mutableMapOf() + lastDateWithPriorityDataMap.clear() + exceptionMessage = "" + forceFail = false } } diff --git a/framework/java/android/health/connect/AggregateRecordsResponse.java b/framework/java/android/health/connect/AggregateRecordsResponse.java index 83bbc184..d4579494 100644 --- a/framework/java/android/health/connect/AggregateRecordsResponse.java +++ b/framework/java/android/health/connect/AggregateRecordsResponse.java @@ -131,6 +131,22 @@ public final class AggregateRecordsResponse<T> { } /** + * Returns {@link ZoneOffset} of the first {@link AggregationType}. + * + * @hide + */ + @Nullable + public ZoneOffset getFirstZoneOffset() { + AggregationType<T> firstAggregationType = getFirstAggregationType(); + return firstAggregationType != null ? getZoneOffset(firstAggregationType) : null; + } + + @Nullable + private AggregationType<T> getFirstAggregationType() { + return mAggregateResults.keySet().stream().findFirst().orElse(null); + } + + /** * Returns a set of {@link DataOrigin}s for the underlying aggregation record, empty set if the * corresponding aggregation doesn't exist and or if multiple records were present. */ diff --git a/framework/java/android/health/connect/TimeRangeFilterHelper.java b/framework/java/android/health/connect/TimeRangeFilterHelper.java index 4df49f0c..17095467 100644 --- a/framework/java/android/health/connect/TimeRangeFilterHelper.java +++ b/framework/java/android/health/connect/TimeRangeFilterHelper.java @@ -17,6 +17,7 @@ package android.health.connect; import android.annotation.NonNull; +import android.annotation.Nullable; import java.time.Instant; import java.time.LocalDateTime; @@ -75,4 +76,15 @@ public final class TimeRangeFilterHelper { public static long getMillisOfLocalTime(LocalDateTime time) { return time.toInstant(LOCAL_TIME_ZERO_OFFSET).toEpochMilli(); } + + /** + * Converts the provided {@link LocalDateTime} to {@link Instant} using the provided {@link + * ZoneOffset} if it's not null, or using the system default zone offset otherwise. + */ + public static Instant getInstantFromLocalTime( + @NonNull LocalDateTime time, @Nullable ZoneOffset zoneOffset) { + return zoneOffset != null + ? time.toInstant(zoneOffset) + : time.toInstant(ZoneOffset.systemDefault().getRules().getOffset(time)); + } } diff --git a/framework/java/android/health/connect/aidl/AggregateDataResponseParcel.java b/framework/java/android/health/connect/aidl/AggregateDataResponseParcel.java index 230e3ab6..5439c405 100644 --- a/framework/java/android/health/connect/aidl/AggregateDataResponseParcel.java +++ b/framework/java/android/health/connect/aidl/AggregateDataResponseParcel.java @@ -18,6 +18,7 @@ package android.health.connect.aidl; import static android.health.connect.Constants.DEFAULT_INT; import static android.health.connect.Constants.DEFAULT_LONG; +import static android.health.connect.TimeRangeFilterHelper.getInstantFromLocalTime; import android.annotation.NonNull; import android.annotation.Nullable; @@ -159,21 +160,69 @@ public class AggregateDataResponseParcel implements Parcelable { getAggregateDataResponseGroupedByDuration() { Objects.requireNonNull(mDuration); - List<AggregateRecordsGroupedByDurationResponse<?>> - aggregateRecordsGroupedByDurationResponse = new ArrayList<>(); - long mStartTime = TimeRangeFilterHelper.getFilterStartTimeMillis(mTimeRangeFilter); - long mEndTime = TimeRangeFilterHelper.getFilterEndTimeMillis(mTimeRangeFilter); - long mDelta = getDurationDelta(mDuration); - for (AggregateRecordsResponse<?> aggregateRecordsResponse : mAggregateRecordsResponses) { - aggregateRecordsGroupedByDurationResponse.add( + if (mAggregateRecordsResponses.isEmpty()) { + return List.of(); + } + + if (mTimeRangeFilter instanceof LocalTimeRangeFilter timeFilter) { + return getAggregateDataResponseForLocalTimeGroupedByDuration( + timeFilter.getStartTime(), timeFilter.getEndTime()); + } + + if (mTimeRangeFilter instanceof TimeInstantRangeFilter timeFilter) { + return getAggregateDataResponseForInstantTimeGroupedByDuration( + timeFilter.getStartTime(), timeFilter.getEndTime()); + } + + throw new IllegalArgumentException( + "Invalid time filter object. Object should be either TimeInstantRangeFilter or " + + "LocalTimeRangeFilter."); + } + + private List<AggregateRecordsGroupedByDurationResponse<?>> + getAggregateDataResponseForLocalTimeGroupedByDuration( + LocalDateTime startTime, LocalDateTime endTime) { + List<AggregateRecordsGroupedByDurationResponse<?>> responses = new ArrayList<>(); + Duration bucketStartTimeOffset = Duration.ZERO; + for (AggregateRecordsResponse<?> response : mAggregateRecordsResponses) { + ZoneOffset zoneOffset = response.getFirstZoneOffset(); + Instant endTimeInstant = getInstantFromLocalTime(endTime, zoneOffset); + Instant bucketStartTime = + getInstantFromLocalTime(startTime, zoneOffset).plus(bucketStartTimeOffset); + Instant bucketEndTime = bucketStartTime.plus(mDuration); + if (bucketEndTime.isAfter(endTimeInstant)) { + bucketEndTime = endTimeInstant; + } + + responses.add( new AggregateRecordsGroupedByDurationResponse<>( - getDurationInstant(mStartTime), - getDurationInstant(Math.min(mStartTime + mDelta, mEndTime)), - aggregateRecordsResponse.getAggregateResults())); - mStartTime += mDelta; + bucketStartTime, bucketEndTime, response.getAggregateResults())); + bucketStartTimeOffset = bucketStartTimeOffset.plus(mDuration); + } + + return responses; + } + + private List<AggregateRecordsGroupedByDurationResponse<?>> + getAggregateDataResponseForInstantTimeGroupedByDuration( + Instant startTime, Instant endTime) { + List<AggregateRecordsGroupedByDurationResponse<?>> responses = new ArrayList<>(); + Duration offsetDuration = Duration.ZERO; + for (AggregateRecordsResponse<?> response : mAggregateRecordsResponses) { + Instant buckedStartTime = startTime.plus(offsetDuration); + Instant buckedEndTime = buckedStartTime.plus(mDuration); + if (buckedEndTime.isAfter(endTime)) { + buckedEndTime = endTime; + } + + responses.add( + new AggregateRecordsGroupedByDurationResponse<>( + buckedStartTime, buckedEndTime, response.getAggregateResults())); + + offsetDuration = offsetDuration.plus(mDuration); } - return aggregateRecordsGroupedByDurationResponse; + return responses; } /** diff --git a/framework/java/android/health/connect/aidl/DeletedLogsParcel.java b/framework/java/android/health/connect/aidl/DeletedLogsParcel.java new file mode 100644 index 00000000..38915241 --- /dev/null +++ b/framework/java/android/health/connect/aidl/DeletedLogsParcel.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.health.connect.aidl; + +import android.annotation.NonNull; +import android.health.connect.changelog.ChangeLogsResponse.DeletedLog; +import android.health.connect.internal.ParcelUtils; +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.ArrayList; +import java.util.List; + +/** + * A {@link Parcelable} that reads and writes {@link DeletedLog}s. + * + * @hide + */ +public final class DeletedLogsParcel implements Parcelable { + + @NonNull + public static final Creator<DeletedLogsParcel> CREATOR = + new Creator<>() { + @Override + public DeletedLogsParcel createFromParcel(Parcel in) { + return new DeletedLogsParcel(in); + } + + @Override + public DeletedLogsParcel[] newArray(int size) { + return new DeletedLogsParcel[size]; + } + }; + + private final List<DeletedLog> mDeletedLogs; + + public DeletedLogsParcel(@NonNull List<DeletedLog> deletedLogs) { + mDeletedLogs = deletedLogs; + } + + private DeletedLogsParcel(@NonNull Parcel in) { + in = ParcelUtils.getParcelForSharedMemoryIfRequired(in); + int size = in.readInt(); + mDeletedLogs = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + String id = in.readString(); + long time = in.readLong(); + mDeletedLogs.add(new DeletedLog(id, time)); + } + } + + @NonNull + public List<DeletedLog> getDeletedLogs() { + return mDeletedLogs; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + ParcelUtils.putToRequiredMemory(dest, flags, this::writeToParcelInternal); + } + + private void writeToParcelInternal(@NonNull Parcel dest) { + dest.writeInt(mDeletedLogs.size()); + for (DeletedLog deletedLog : mDeletedLogs) { + dest.writeString(deletedLog.getDeletedRecordId()); + dest.writeLong(deletedLog.getDeletedTime().toEpochMilli()); + } + } +} diff --git a/framework/java/android/health/connect/aidl/InsertRecordsResponseParcel.java b/framework/java/android/health/connect/aidl/InsertRecordsResponseParcel.java index 19d3eae6..610f0486 100644 --- a/framework/java/android/health/connect/aidl/InsertRecordsResponseParcel.java +++ b/framework/java/android/health/connect/aidl/InsertRecordsResponseParcel.java @@ -18,6 +18,7 @@ package android.health.connect.aidl; import android.annotation.NonNull; import android.health.connect.HealthConnectManager; +import android.health.connect.internal.ParcelUtils; import android.os.Parcel; import android.os.Parcelable; @@ -39,7 +40,8 @@ public class InsertRecordsResponseParcel implements Parcelable { mUids = uids; } - protected InsertRecordsResponseParcel(Parcel in) { + private InsertRecordsResponseParcel(Parcel in) { + in = ParcelUtils.getParcelForSharedMemoryIfRequired(in); mUids = in.createStringArrayList(); } @@ -50,7 +52,7 @@ public class InsertRecordsResponseParcel implements Parcelable { @NonNull public static final Creator<InsertRecordsResponseParcel> CREATOR = - new Creator<InsertRecordsResponseParcel>() { + new Creator<>() { @Override public InsertRecordsResponseParcel createFromParcel(Parcel in) { return new InsertRecordsResponseParcel(in); @@ -69,6 +71,10 @@ public class InsertRecordsResponseParcel implements Parcelable { @Override public void writeToParcel(@NonNull Parcel dest, int flags) { + ParcelUtils.putToRequiredMemory(dest, flags, this::writeToParcelInternal); + } + + private void writeToParcelInternal(@NonNull Parcel dest) { dest.writeStringList(mUids); } } diff --git a/framework/java/android/health/connect/changelog/ChangeLogsResponse.java b/framework/java/android/health/connect/changelog/ChangeLogsResponse.java index c2a6839f..7a9c0dbd 100644 --- a/framework/java/android/health/connect/changelog/ChangeLogsResponse.java +++ b/framework/java/android/health/connect/changelog/ChangeLogsResponse.java @@ -18,6 +18,7 @@ package android.health.connect.changelog; import android.annotation.NonNull; import android.health.connect.HealthConnectManager; +import android.health.connect.aidl.DeletedLogsParcel; import android.health.connect.aidl.RecordsParcel; import android.health.connect.datatypes.Record; import android.health.connect.internal.datatypes.RecordInternal; @@ -70,14 +71,9 @@ public final class ChangeLogsResponse implements Parcelable { RecordsParcel.class.getClassLoader(), RecordsParcel.class) .getRecords()); - int size = in.readInt(); - List<DeletedLog> deletedLogs = new ArrayList<>(size); - for (int i = 0; i < size; i++) { - String id = in.readString(); - long time = in.readLong(); - deletedLogs.add(new DeletedLog(id, time)); - } - mDeletedLogs = deletedLogs; + mDeletedLogs = + in.readParcelable(DeletedLogsParcel.class.getClassLoader(), DeletedLogsParcel.class) + .getDeletedLogs(); mNextChangesToken = in.readString(); mHasMorePages = in.readBoolean(); } @@ -142,11 +138,7 @@ public final class ChangeLogsResponse implements Parcelable { recordInternal.add(record.toRecordInternal()); } dest.writeParcelable(new RecordsParcel(recordInternal), 0); - dest.writeInt(mDeletedLogs.size()); - for (DeletedLog deletedLog : mDeletedLogs) { - dest.writeString(deletedLog.getDeletedRecordId()); - dest.writeLong(deletedLog.getDeletedTime().toEpochMilli()); - } + dest.writeParcelable(new DeletedLogsParcel(mDeletedLogs), 0); dest.writeString(mNextChangesToken); dest.writeBoolean(mHasMorePages); } diff --git a/framework/java/android/health/connect/datatypes/CyclingPedalingCadenceRecord.java b/framework/java/android/health/connect/datatypes/CyclingPedalingCadenceRecord.java index 9b8e49bc..5043eeba 100644 --- a/framework/java/android/health/connect/datatypes/CyclingPedalingCadenceRecord.java +++ b/framework/java/android/health/connect/datatypes/CyclingPedalingCadenceRecord.java @@ -16,6 +16,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.validation.ValidationUtils; import android.health.connect.internal.datatypes.CyclingPedalingCadenceRecordInternal; @@ -169,7 +170,7 @@ public final class CyclingPedalingCadenceRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof CyclingPedalingCadenceRecordSample) { CyclingPedalingCadenceRecordSample other = (CyclingPedalingCadenceRecordSample) object; @@ -294,7 +295,7 @@ public final class CyclingPedalingCadenceRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object)) { CyclingPedalingCadenceRecord other = (CyclingPedalingCadenceRecord) object; if (getSamples().size() != other.getSamples().size()) return false; diff --git a/framework/java/android/health/connect/datatypes/DataOrigin.java b/framework/java/android/health/connect/datatypes/DataOrigin.java index 64db503c..34ac9581 100644 --- a/framework/java/android/health/connect/datatypes/DataOrigin.java +++ b/framework/java/android/health/connect/datatypes/DataOrigin.java @@ -17,6 +17,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import java.util.Objects; @@ -70,7 +71,7 @@ public final class DataOrigin { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (this == object) return true; if (object instanceof DataOrigin) { DataOrigin other = (DataOrigin) object; diff --git a/framework/java/android/health/connect/datatypes/Device.java b/framework/java/android/health/connect/datatypes/Device.java index e813d2cb..f3e7e484 100644 --- a/framework/java/android/health/connect/datatypes/Device.java +++ b/framework/java/android/health/connect/datatypes/Device.java @@ -129,7 +129,7 @@ public final class Device { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (this == object) return true; if (object instanceof Device) { Device other = (Device) object; diff --git a/framework/java/android/health/connect/datatypes/HeartRateRecord.java b/framework/java/android/health/connect/datatypes/HeartRateRecord.java index a96b02c1..06414268 100644 --- a/framework/java/android/health/connect/datatypes/HeartRateRecord.java +++ b/framework/java/android/health/connect/datatypes/HeartRateRecord.java @@ -19,6 +19,7 @@ package android.health.connect.datatypes; import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_HEART_RATE; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.validation.ValidationUtils; import android.health.connect.internal.datatypes.HeartRateRecordInternal; @@ -115,7 +116,7 @@ public final class HeartRateRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof HeartRateRecord) { HeartRateRecord other = (HeartRateRecord) object; if (getSamples().size() != other.getSamples().size()) return false; @@ -193,7 +194,7 @@ public final class HeartRateRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof HeartRateSample) { HeartRateSample other = (HeartRateSample) object; return getBeatsPerMinute() == other.getBeatsPerMinute() diff --git a/framework/java/android/health/connect/datatypes/InstantRecord.java b/framework/java/android/health/connect/datatypes/InstantRecord.java index eb91584a..a7129180 100644 --- a/framework/java/android/health/connect/datatypes/InstantRecord.java +++ b/framework/java/android/health/connect/datatypes/InstantRecord.java @@ -17,6 +17,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import java.time.Instant; import java.time.ZoneOffset; @@ -75,7 +76,7 @@ public abstract class InstantRecord extends Record { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object)) { InstantRecord other = (InstantRecord) object; return this.getTime().toEpochMilli() == other.getTime().toEpochMilli() diff --git a/framework/java/android/health/connect/datatypes/IntervalRecord.java b/framework/java/android/health/connect/datatypes/IntervalRecord.java index 03fbd096..5d55c5c3 100644 --- a/framework/java/android/health/connect/datatypes/IntervalRecord.java +++ b/framework/java/android/health/connect/datatypes/IntervalRecord.java @@ -16,6 +16,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import java.time.Instant; import java.time.ZoneOffset; @@ -102,7 +103,7 @@ public abstract class IntervalRecord extends Record { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object)) { IntervalRecord other = (IntervalRecord) object; return getStartTime().toEpochMilli() == other.getStartTime().toEpochMilli() diff --git a/framework/java/android/health/connect/datatypes/MenstruationPeriodRecord.java b/framework/java/android/health/connect/datatypes/MenstruationPeriodRecord.java index 6398d896..d533e74c 100644 --- a/framework/java/android/health/connect/datatypes/MenstruationPeriodRecord.java +++ b/framework/java/android/health/connect/datatypes/MenstruationPeriodRecord.java @@ -16,6 +16,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.internal.datatypes.MenstruationPeriodRecordInternal; import java.time.Instant; @@ -52,7 +53,7 @@ public final class MenstruationPeriodRecord extends IntervalRecord { * otherwise. */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { return super.equals(object); } diff --git a/framework/java/android/health/connect/datatypes/Metadata.java b/framework/java/android/health/connect/datatypes/Metadata.java index 9f2daeab..a80b6e33 100644 --- a/framework/java/android/health/connect/datatypes/Metadata.java +++ b/framework/java/android/health/connect/datatypes/Metadata.java @@ -189,7 +189,7 @@ public final class Metadata { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (this == object) return true; if (object instanceof Metadata) { Metadata other = (Metadata) object; diff --git a/framework/java/android/health/connect/datatypes/PowerRecord.java b/framework/java/android/health/connect/datatypes/PowerRecord.java index 70e35fda..859d80bf 100644 --- a/framework/java/android/health/connect/datatypes/PowerRecord.java +++ b/framework/java/android/health/connect/datatypes/PowerRecord.java @@ -18,6 +18,7 @@ package android.health.connect.datatypes; import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_POWER; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.units.Power; import android.health.connect.datatypes.validation.ValidationUtils; @@ -280,7 +281,7 @@ public final class PowerRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof PowerRecord) { PowerRecord other = (PowerRecord) object; if (getSamples().size() != other.getSamples().size()) return false; diff --git a/framework/java/android/health/connect/datatypes/Record.java b/framework/java/android/health/connect/datatypes/Record.java index b9804fd9..15261529 100644 --- a/framework/java/android/health/connect/datatypes/Record.java +++ b/framework/java/android/health/connect/datatypes/Record.java @@ -19,6 +19,7 @@ package android.health.connect.datatypes; import static android.health.connect.datatypes.validation.ValidationUtils.validateIntDefValue; import android.annotation.NonNull; +import android.annotation.Nullable; import android.annotation.SystemApi; import android.health.connect.internal.datatypes.RecordInternal; @@ -67,7 +68,7 @@ public abstract class Record { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (this == object) return true; if (Objects.isNull(object)) { return false; diff --git a/framework/java/android/health/connect/datatypes/SpeedRecord.java b/framework/java/android/health/connect/datatypes/SpeedRecord.java index 5259ae69..bcc00539 100644 --- a/framework/java/android/health/connect/datatypes/SpeedRecord.java +++ b/framework/java/android/health/connect/datatypes/SpeedRecord.java @@ -16,6 +16,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.units.Velocity; import android.health.connect.datatypes.validation.ValidationUtils; @@ -165,7 +166,7 @@ public final class SpeedRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof SpeedRecordSample) { SpeedRecordSample other = (SpeedRecordSample) object; return getSpeed().equals(other.getSpeed()) @@ -286,7 +287,7 @@ public final class SpeedRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof SpeedRecord) { SpeedRecord other = (SpeedRecord) object; if (getSamples().size() != other.getSamples().size()) return false; diff --git a/framework/java/android/health/connect/datatypes/StepsCadenceRecord.java b/framework/java/android/health/connect/datatypes/StepsCadenceRecord.java index 6a026563..6b3fdeec 100644 --- a/framework/java/android/health/connect/datatypes/StepsCadenceRecord.java +++ b/framework/java/android/health/connect/datatypes/StepsCadenceRecord.java @@ -16,6 +16,7 @@ package android.health.connect.datatypes; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.validation.ValidationUtils; import android.health.connect.internal.datatypes.StepsCadenceRecordInternal; @@ -162,7 +163,7 @@ public final class StepsCadenceRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof StepsCadenceRecordSample) { StepsCadenceRecordSample other = (StepsCadenceRecordSample) object; return getRate() == other.getRate() @@ -282,7 +283,7 @@ public final class StepsCadenceRecord extends IntervalRecord { * @return {@code true} if this object is the same as the obj */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof StepsCadenceRecord) { StepsCadenceRecord other = (StepsCadenceRecord) object; if (getSamples().size() != other.getSamples().size()) return false; diff --git a/framework/java/android/health/connect/datatypes/TotalCaloriesBurnedRecord.java b/framework/java/android/health/connect/datatypes/TotalCaloriesBurnedRecord.java index 729fc28e..b75f14ed 100644 --- a/framework/java/android/health/connect/datatypes/TotalCaloriesBurnedRecord.java +++ b/framework/java/android/health/connect/datatypes/TotalCaloriesBurnedRecord.java @@ -18,6 +18,7 @@ package android.health.connect.datatypes; import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_TOTAL_CALORIES_BURNED; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.units.Energy; import android.health.connect.datatypes.validation.ValidationUtils; @@ -95,7 +96,7 @@ public final class TotalCaloriesBurnedRecord extends IntervalRecord { * otherwise. */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof TotalCaloriesBurnedRecord) { TotalCaloriesBurnedRecord other = (TotalCaloriesBurnedRecord) object; return this.getEnergy().equals(other.getEnergy()); diff --git a/framework/java/android/health/connect/datatypes/WheelchairPushesRecord.java b/framework/java/android/health/connect/datatypes/WheelchairPushesRecord.java index bcb30c41..d308e92f 100644 --- a/framework/java/android/health/connect/datatypes/WheelchairPushesRecord.java +++ b/framework/java/android/health/connect/datatypes/WheelchairPushesRecord.java @@ -20,6 +20,7 @@ import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_ import android.annotation.IntRange; import android.annotation.NonNull; +import android.annotation.Nullable; import android.health.connect.HealthConnectManager; import android.health.connect.datatypes.validation.ValidationUtils; import android.health.connect.internal.datatypes.WheelchairPushesRecordInternal; @@ -94,7 +95,7 @@ public final class WheelchairPushesRecord extends IntervalRecord { * otherwise. */ @Override - public boolean equals(@NonNull Object object) { + public boolean equals(@Nullable Object object) { if (super.equals(object) && object instanceof WheelchairPushesRecord) { WheelchairPushesRecord other = (WheelchairPushesRecord) object; return this.getCount() == other.getCount(); diff --git a/framework/java/android/health/connect/internal/ParcelUtils.java b/framework/java/android/health/connect/internal/ParcelUtils.java index 8a8f2dac..06f487ac 100644 --- a/framework/java/android/health/connect/internal/ParcelUtils.java +++ b/framework/java/android/health/connect/internal/ParcelUtils.java @@ -81,10 +81,11 @@ public final class ParcelUtils { parcelRunnable.writeToParcel(dataParcel); final int dataParcelSize = dataParcel.dataSize(); if (dataParcelSize > IPC_PARCEL_LIMIT) { - SharedMemory sharedMemory = - ParcelUtils.getSharedMemoryForParcel(dataParcel, dataParcelSize); - dest.writeInt(USING_SHARED_MEMORY); - sharedMemory.writeToParcel(dest, flags); + try (SharedMemory sharedMemory = + ParcelUtils.getSharedMemoryForParcel(dataParcel, dataParcelSize)) { + dest.writeInt(USING_SHARED_MEMORY); + sharedMemory.writeToParcel(dest, flags); + } } else { dest.writeInt(USING_PARCEL); parcelRunnable.writeToParcel(dest); diff --git a/service/java/com/android/server/healthconnect/HealthConnectDeviceConfigManager.java b/service/java/com/android/server/healthconnect/HealthConnectDeviceConfigManager.java index 5251636b..b2395ce9 100644 --- a/service/java/com/android/server/healthconnect/HealthConnectDeviceConfigManager.java +++ b/service/java/com/android/server/healthconnect/HealthConnectDeviceConfigManager.java @@ -125,10 +125,14 @@ public class HealthConnectDeviceConfigManager implements DeviceConfig.OnProperti private static final boolean SESSION_DATATYPE_DEFAULT_FLAG_VALUE = true; private static final boolean EXERCISE_ROUTE_DEFAULT_FLAG_VALUE = true; public static final boolean ENABLE_RATE_LIMITER_DEFAULT_FLAG_VALUE = true; - public static final int QUOTA_BUCKET_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE = 1000; - public static final int QUOTA_BUCKET_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE = 8000; - public static final int QUOTA_BUCKET_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE = 1000; - public static final int QUOTA_BUCKET_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE = 8000; + public static final int QUOTA_BUCKET_READS_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE = 2000; + public static final int QUOTA_BUCKET_READS_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE = 16000; + public static final int QUOTA_BUCKET_READS_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE = 1000; + public static final int QUOTA_BUCKET_READS_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE = 8000; + public static final int QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE = 1000; + public static final int QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE = 8000; + public static final int QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE = 1000; + public static final int QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE = 8000; public static final int CHUNK_SIZE_LIMIT_IN_BYTES_DEFAULT_FLAG_VALUE = 5000000; public static final int RECORD_SIZE_LIMIT_IN_BYTES_DEFAULT_FLAG_VALUE = 1000000; public static final int DATA_PUSH_LIMIT_PER_APP_15M_DEFAULT_FLAG_VALUE = 35000000; @@ -512,49 +516,49 @@ public class HealthConnectDeviceConfigManager implements DeviceConfig.OnProperti DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_READ_REQUESTS_PER_24H_FOREGROUND_FLAG, - QUOTA_BUCKET_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_READS_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_READS_PER_24H_BACKGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_READ_REQUESTS_PER_24H_BACKGROUND_FLAG, - QUOTA_BUCKET_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_READS_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_READS_PER_15M_FOREGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_READ_REQUESTS_PER_15M_FOREGROUND_FLAG, - QUOTA_BUCKET_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_READS_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_READS_PER_15M_BACKGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_READ_REQUESTS_PER_15M_BACKGROUND_FLAG, - QUOTA_BUCKET_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_READS_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_WRITE_REQUESTS_PER_24H_FOREGROUND_FLAG, - QUOTA_BUCKET_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_WRITE_REQUESTS_PER_24H_BACKGROUND_FLAG, - QUOTA_BUCKET_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_WRITE_REQUESTS_PER_15M_FOREGROUND_FLAG, - QUOTA_BUCKET_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND, DeviceConfig.getInt( DeviceConfig.NAMESPACE_HEALTH_FITNESS, MAX_WRITE_REQUESTS_PER_15M_BACKGROUND_FLAG, - QUOTA_BUCKET_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE)); + QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE)); quotaBucketToMaxRollingQuotaMap.put( QuotaBucket.QUOTA_BUCKET_DATA_PUSH_LIMIT_PER_APP_15M, DeviceConfig.getInt( diff --git a/service/java/com/android/server/healthconnect/HealthConnectServiceImpl.java b/service/java/com/android/server/healthconnect/HealthConnectServiceImpl.java index 5db5b39a..ae903796 100644 --- a/service/java/com/android/server/healthconnect/HealthConnectServiceImpl.java +++ b/service/java/com/android/server/healthconnect/HealthConnectServiceImpl.java @@ -150,6 +150,7 @@ import java.io.IOException; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -488,6 +489,8 @@ final class HealthConnectServiceImpl extends IHealthConnectService.Stub { } long startDateAccess; + // TODO(b/309776578): Consider making background reads possible for + // aggregations when only using own data if (!holdsDataManagementPermission) { boolean isInForeground = mAppOpsManagerLocal.isUidInForeground(uid); logger.setCallerForegroundState(isInForeground); @@ -504,13 +507,24 @@ final class HealthConnectServiceImpl extends IHealthConnectService.Stub { RateLimiter.QuotaCategory.QUOTA_CATEGORY_READ, isInForeground, logger); - mDataPermissionEnforcer.enforceRecordIdsReadPermissions( - recordTypesToTest, attributionSource); + boolean enforceSelfRead = + mDataPermissionEnforcer.enforceReadAccessAndGetEnforceSelfRead( + recordTypesToTest, attributionSource); startDateAccess = mPermissionHelper .getHealthDataStartDateAccessOrThrow( attributionSource.getPackageName(), userHandle) .toEpochMilli(); + maybeEnforceOnlyCallingPackageDataRequested( + request.getPackageFilters(), + attributionSource.getPackageName(), + enforceSelfRead, + "aggregationTypes: " + + Arrays.stream(request.getAggregateIds()) + .mapToObj( + AggregationTypeIdMapper.getInstance() + ::getAggregationTypeFor) + .collect(Collectors.toList())); } else { startDateAccess = request.getStartTime(); } @@ -608,6 +622,20 @@ final class HealthConnectServiceImpl extends IHealthConnectService.Stub { // then enforce self read enforceSelfRead = isOnlySelfReadInBackgroundAllowed(uid, pid); } + if (request.getRecordIdFiltersParcel() == null) { + // Only enforce requested packages if this is a + // ReadRecordsByRequest using filters. Reading by IDs does not have + // data origins specified. + // TODO(b/309778116): Consider throwing an error when reading by Id + maybeEnforceOnlyCallingPackageDataRequested( + request.getPackageFilters(), + callingPackageName, + enforceSelfRead, + "recordType: " + + RecordMapper.getInstance() + .getRecordIdToExternalRecordClassMap() + .get(request.getRecordType())); + } if (Constants.DEBUG) { Slog.d( @@ -753,6 +781,21 @@ final class HealthConnectServiceImpl extends IHealthConnectService.Stub { holdsDataManagementPermission); } + private void maybeEnforceOnlyCallingPackageDataRequested( + List<String> packageFilters, + String callingPackageName, + boolean enforceSelfRead, + String entityFailureMessage) { + if (enforceSelfRead + && (packageFilters.size() != 1 + || !packageFilters.get(0).equals(callingPackageName))) { + throwSecurityException( + "Caller does not have permission to read data for the following (" + + entityFailureMessage + + ") from other applications."); + } + } + /** * Updates {@code recordsParcel} into the HealthConnect database. * diff --git a/service/java/com/android/server/healthconnect/permission/DataPermissionEnforcer.java b/service/java/com/android/server/healthconnect/permission/DataPermissionEnforcer.java index f0acfc6f..62146e9c 100644 --- a/service/java/com/android/server/healthconnect/permission/DataPermissionEnforcer.java +++ b/service/java/com/android/server/healthconnect/permission/DataPermissionEnforcer.java @@ -104,6 +104,26 @@ public class DataPermissionEnforcer { return enforceSelfRead; } + // TODO(b/312952346): Consider refactoring how permission enforcement is done within + // HealthConnectServiceImpl. This goes beyond just this method. + /** + * Enforces that the caller has either read or write permissions for all the given recordTypes, + * and returns {@code true} if the caller is allowed to read only records written by itself, + * false otherwise. + * + * @throws SecurityException if the app has neither read nor write permissions for any of the + * specified record types. + */ + public boolean enforceReadAccessAndGetEnforceSelfRead( + List<Integer> recordTypes, AttributionSource attributionSource) { + boolean enforceSelfRead = false; + for (int recordTypeId : recordTypes) { + enforceSelfRead |= + enforceReadAccessAndGetEnforceSelfRead(recordTypeId, attributionSource); + } + return enforceSelfRead; + } + /** * Enforces that caller has all write permissions to write given records. Includes permissions * for writing optional extra data if it's present in given records. diff --git a/service/java/com/android/server/healthconnect/storage/datatypehelpers/AccessLogsHelper.java b/service/java/com/android/server/healthconnect/storage/datatypehelpers/AccessLogsHelper.java index 8a104a21..4e1f7e0e 100644 --- a/service/java/com/android/server/healthconnect/storage/datatypehelpers/AccessLogsHelper.java +++ b/service/java/com/android/server/healthconnect/storage/datatypehelpers/AccessLogsHelper.java @@ -16,7 +16,6 @@ package com.android.server.healthconnect.storage.datatypehelpers; -import static com.android.server.healthconnect.storage.datatypehelpers.InstantRecordHelper.TIME_COLUMN_NAME; import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME; import static com.android.server.healthconnect.storage.utils.StorageUtils.DELIMITER; import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_NOT_NULL; @@ -154,7 +153,7 @@ public final class AccessLogsHelper extends DatabaseHelper { public DeleteTableRequest getDeleteRequestForAutoDelete() { return new DeleteTableRequest(TABLE_NAME) .setTimeFilter( - TIME_COLUMN_NAME, + ACCESS_TIME_COLUMN_NAME, Instant.EPOCH.toEpochMilli(), Instant.now() .minus(DEFAULT_ACCESS_LOG_TIME_PERIOD_IN_DAYS, ChronoUnit.DAYS) diff --git a/service/java/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelper.java b/service/java/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelper.java index a2cea142..98331042 100644 --- a/service/java/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelper.java +++ b/service/java/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelper.java @@ -401,11 +401,11 @@ public class HealthDataCategoryPriorityHelper extends DatabaseHelper { HealthConnectDeviceConfigManager.getInitialisedInstance() .isAggregationSourceControlsEnabled(); // Candidates to be added to the priority list - Map<Integer, Set<Long>> dataCategoryToAppIdMapHavingPermission = + Map<Integer, List<Long>> dataCategoryToAppIdMapHavingPermission = getHealthDataCategoryToAppIdPriorityMap().entrySet().stream() .collect( Collectors.toMap( - Map.Entry::getKey, e -> new HashSet<>(e.getValue()))); + Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); // Candidates to be removed from the priority list Map<Integer, Set<Long>> dataCategoryToAppIdMapWithoutPermission = getHealthDataCategoryToAppIdPriorityMap().entrySet().stream() @@ -421,10 +421,11 @@ public class HealthDataCategoryPriorityHelper extends DatabaseHelper { long appInfoId = appInfoHelper.getOrInsertAppInfoId(packageInfo.packageName, context); for (int dataCategory : dataCategoriesWithWritePermissionsForThisPackage) { - Set<Long> appIdsHavingPermission = + List<Long> appIdsHavingPermission = dataCategoryToAppIdMapHavingPermission.getOrDefault( - dataCategory, new HashSet<>()); - if (appIdsHavingPermission.add(appInfoId)) { + dataCategory, new ArrayList<>()); + if (!appIdsHavingPermission.contains(appInfoId) + && appIdsHavingPermission.add(appInfoId)) { dataCategoryToAppIdMapHavingPermission.put( dataCategory, appIdsHavingPermission); } @@ -512,7 +513,7 @@ public class HealthDataCategoryPriorityHelper extends DatabaseHelper { } private synchronized void updateTableWithNewPriorityList( - Map<Integer, Set<Long>> healthDataCategoryToAppIdPriorityMap) { + Map<Integer, List<Long>> healthDataCategoryToAppIdPriorityMap) { for (int dataCategory : healthDataCategoryToAppIdPriorityMap.keySet()) { List<Long> appInfoIdList = List.copyOf(healthDataCategoryToAppIdPriorityMap.get(dataCategory)); diff --git a/service/java/com/android/server/healthconnect/storage/datatypehelpers/aggregation/PriorityRecordsAggregator.java b/service/java/com/android/server/healthconnect/storage/datatypehelpers/aggregation/PriorityRecordsAggregator.java index 2e68637a..5af3e163 100644 --- a/service/java/com/android/server/healthconnect/storage/datatypehelpers/aggregation/PriorityRecordsAggregator.java +++ b/service/java/com/android/server/healthconnect/storage/datatypehelpers/aggregation/PriorityRecordsAggregator.java @@ -190,6 +190,13 @@ public class PriorityRecordsAggregator { return null; } + // TODO(b/313924267): workaround for b/308467442, should be remove once we have a long term + // solution + if (data.getStartTime() > data.getEndTime()) { + // skip records with start time > end time to keep the algorithm functional + return null; + } + mTimestampsBuffer.add(data.getStartTimestamp()); mTimestampsBuffer.add(data.getEndTimestamp()); return data; diff --git a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/DailyLogsTests.java b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/DailyLogsTests.java index f8c3735d..e7fd2c7a 100644 --- a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/DailyLogsTests.java +++ b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/DailyLogsTests.java @@ -21,12 +21,16 @@ import static android.healthconnect.cts.testhelper.TestHelperUtils.getBloodPress import static android.healthconnect.cts.testhelper.TestHelperUtils.getHeartRateRecord; import static android.healthconnect.cts.testhelper.TestHelperUtils.getStepsRecord; import static android.healthconnect.cts.testhelper.TestHelperUtils.insertRecords; +import static android.healthconnect.cts.testhelper.TestHelperUtils.queryAccessLogs; + +import static com.google.common.truth.Truth.assertThat; import android.health.connect.HealthConnectManager; import androidx.test.InstrumentationRegistry; import com.android.compatibility.common.util.NonApiTest; +import com.android.compatibility.common.util.SystemUtil; import org.junit.Test; @@ -46,10 +50,42 @@ public class DailyLogsTests { InstrumentationRegistry.getContext().getSystemService(HealthConnectManager.class); @Test - public void testHealthConnectDatabaseStats() throws Exception { - insertRecords( - List.of(getStepsRecord(), getBloodPressureRecord(), getHeartRateRecord()), - mHealthConnectManager); + public void testInsertRecordsSucceed() throws Exception { + assertThat( + insertRecords( + List.of( + getStepsRecord(), + getBloodPressureRecord(), + getHeartRateRecord()), + mHealthConnectManager)) + .hasSize(3); + } + + @Test + public void testHealthConnectAccessLogsEqualsZero() throws Exception { + SystemUtil.runWithShellPermissionIdentity( + () -> { + assertThat(queryAccessLogs(mHealthConnectManager)).hasSize(0); + }, + "android.permission.MANAGE_HEALTH_DATA"); + } + + @Test + public void testHealthConnectAccessLogsEqualsOne() throws Exception { + SystemUtil.runWithShellPermissionIdentity( + () -> { + assertThat(queryAccessLogs(mHealthConnectManager)).hasSize(1); + }, + "android.permission.MANAGE_HEALTH_DATA"); + } + + @Test + public void testHealthConnectAccessLogsEqualsTwo() throws Exception { + SystemUtil.runWithShellPermissionIdentity( + () -> { + assertThat(queryAccessLogs(mHealthConnectManager)).hasSize(2); + }, + "android.permission.MANAGE_HEALTH_DATA"); } /** diff --git a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectServiceLogsTests.java b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectServiceLogsTests.java index 9eaa71ac..4c55b2b7 100644 --- a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectServiceLogsTests.java +++ b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectServiceLogsTests.java @@ -43,6 +43,7 @@ import android.health.connect.changelog.ChangeLogTokenResponse; import android.health.connect.changelog.ChangeLogsRequest; import android.health.connect.changelog.ChangeLogsResponse; import android.health.connect.datatypes.BloodPressureRecord; +import android.health.connect.datatypes.DataOrigin; import android.health.connect.datatypes.HeightRecord; import android.health.connect.datatypes.Record; import android.health.connect.datatypes.StepsRecord; @@ -161,6 +162,8 @@ public class HealthConnectServiceLogsTests { mHealthConnectManager.readRecords( new ReadRecordsRequestUsingFilters.Builder<>(StepsRecord.class) .setTimeRangeFilter(getDefaultTimeRangeFilter()) + .addDataOrigins( + new DataOrigin.Builder().setPackageName(MY_PACKAGE_NAME).build()) .setPageSize(1) .build(), Executors.newSingleThreadExecutor(), diff --git a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectTestHelper.java b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectTestHelper.java index b882b89c..07d4a759 100644 --- a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectTestHelper.java +++ b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/HealthConnectTestHelper.java @@ -20,6 +20,7 @@ import static android.healthconnect.cts.lib.MultiAppTestUtils.APP_PKG_NAME_USED_ import static android.healthconnect.cts.lib.MultiAppTestUtils.CHANGE_LOGS_RESPONSE; import static android.healthconnect.cts.lib.MultiAppTestUtils.CHANGE_LOG_TOKEN; import static android.healthconnect.cts.lib.MultiAppTestUtils.CLIENT_ID; +import static android.healthconnect.cts.lib.MultiAppTestUtils.DATA_ORIGIN_FILTER_PACKAGE_NAMES; import static android.healthconnect.cts.lib.MultiAppTestUtils.DELETE_RECORDS_QUERY; import static android.healthconnect.cts.lib.MultiAppTestUtils.END_TIME; import static android.healthconnect.cts.lib.MultiAppTestUtils.EXERCISE_SESSION; @@ -66,6 +67,7 @@ import android.health.connect.changelog.ChangeLogsResponse; import android.health.connect.datatypes.DataOrigin; import android.health.connect.datatypes.ExerciseSessionRecord; import android.health.connect.datatypes.Record; +import android.health.connect.datatypes.StepsRecord; import android.healthconnect.cts.utils.TestUtils; import android.os.Bundle; @@ -149,10 +151,19 @@ public class HealthConnectTestHelper extends Activity { break; case READ_RECORDS_QUERY: if (bundle.containsKey(READ_USING_DATA_ORIGIN_FILTERS)) { + List<String> dataOriginPackageNames = + bundle.containsKey(DATA_ORIGIN_FILTER_PACKAGE_NAMES) + ? + // if a set of data origin filters is specified, use that + bundle.getStringArrayList(DATA_ORIGIN_FILTER_PACKAGE_NAMES) + : + // otherwise default to this app's package name + List.of(context.getPackageName()); returnIntent = readRecordsUsingDataOriginFilters( queryType, bundle.getStringArrayList(READ_RECORD_CLASS_NAME), + dataOriginPackageNames, context); break; } @@ -461,23 +472,25 @@ public class HealthConnectTestHelper extends Activity { * @return Intent to send back to the main app which is running the tests */ private Intent readRecordsUsingDataOriginFilters( - String queryType, ArrayList<String> recordClassesToRead, Context context) { + String queryType, + ArrayList<String> recordClassesToRead, + List<String> dataOriginPackageNames, + Context context) { final Intent intent = new Intent(queryType); int recordsSize = 0; try { for (String recordClass : recordClassesToRead) { - List<? extends Record> recordsRead = - readRecords( - new ReadRecordsRequestUsingFilters.Builder<>( - (Class<? extends Record>) - Class.forName(recordClass)) - .addDataOrigins( - new DataOrigin.Builder() - .setPackageName(context.getPackageName()) - .build()) - .build(), - context); + ReadRecordsRequestUsingFilters.Builder requestBuilder = + new ReadRecordsRequestUsingFilters.Builder<>( + (Class<? extends Record>) Class.forName(recordClass)); + dataOriginPackageNames.forEach( + packageName -> + requestBuilder.addDataOrigins( + new DataOrigin.Builder() + .setPackageName(packageName) + .build())); + List<? extends Record> recordsRead = readRecords(requestBuilder.build(), context); recordsSize += recordsRead.size(); } } catch (Exception e) { @@ -590,8 +603,15 @@ public class HealthConnectTestHelper extends Activity { Arrays.asList( buildStepsRecord( startTime, endTime, stepsCount, context.getPackageName())); - insertRecords(recordToInsert, context); + List<Record> insertedRecords = insertRecords(recordToInsert, context); + List<TestUtils.RecordTypeAndRecordIds> recordTypeAndRecordIdsList = + new ArrayList<TestUtils.RecordTypeAndRecordIds>(); + recordTypeAndRecordIdsList.add( + new TestUtils.RecordTypeAndRecordIds( + StepsRecord.class.getName(), + List.of(insertedRecords.get(0).getMetadata().getId()))); intent.putExtra(SUCCESS, true); + intent.putExtra(RECORD_IDS, (Serializable) recordTypeAndRecordIdsList); } catch (Exception e) { intent.putExtra(SUCCESS, false); } diff --git a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/TestHelperUtils.java b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/TestHelperUtils.java index 00f93921..6ef33a4e 100644 --- a/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/TestHelperUtils.java +++ b/tests/cts/hostsidetests/healthconnect/HealthConnectTestHelper/src/android/healthconnect/cts/testhelper/TestHelperUtils.java @@ -24,6 +24,7 @@ import android.health.connect.HealthConnectManager; import android.health.connect.InsertRecordsResponse; import android.health.connect.TimeInstantRangeFilter; import android.health.connect.TimeRangeFilter; +import android.health.connect.accesslog.AccessLog; import android.health.connect.datatypes.BloodPressureRecord; import android.health.connect.datatypes.DataOrigin; import android.health.connect.datatypes.HeartRateRecord; @@ -171,6 +172,32 @@ public class TestHelperUtils { } } + /** Query access logs */ + public static List<AccessLog> queryAccessLogs(HealthConnectManager healthConnectManager) + throws InterruptedException { + AtomicReference<List<AccessLog>> response = new AtomicReference<>(new ArrayList<>()); + CountDownLatch latch = new CountDownLatch(1); + assertThat(healthConnectManager).isNotNull(); + + healthConnectManager.queryAccessLogs( + Executors.newSingleThreadExecutor(), + new OutcomeReceiver<>() { + @Override + public void onResult(List<AccessLog> accessLogs) { + response.set(accessLogs); + latch.countDown(); + } + + @Override + public void onError(HealthConnectException exception) { + latch.countDown(); + } + }); + + assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); + return response.get(); + } + /** Deletes the records added by the test app. */ public static void deleteAllRecordsAddedByTestApp(HealthConnectManager healthConnectManager) throws InterruptedException { diff --git a/tests/cts/hostsidetests/healthconnect/device/src/android/healthconnect/cts/device/HealthConnectDeviceTest.java b/tests/cts/hostsidetests/healthconnect/device/src/android/healthconnect/cts/device/HealthConnectDeviceTest.java index 691b44ac..623998a4 100644 --- a/tests/cts/hostsidetests/healthconnect/device/src/android/healthconnect/cts/device/HealthConnectDeviceTest.java +++ b/tests/cts/hostsidetests/healthconnect/device/src/android/healthconnect/cts/device/HealthConnectDeviceTest.java @@ -55,18 +55,24 @@ import static com.android.compatibility.common.util.FeatureUtil.hasSystemFeature import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + import android.app.UiAutomation; +import android.content.Context; import android.health.connect.AggregateRecordsRequest; import android.health.connect.AggregateRecordsResponse; import android.health.connect.HealthConnectException; import android.health.connect.HealthDataCategory; import android.health.connect.HealthPermissions; import android.health.connect.ReadRecordsRequestUsingFilters; +import android.health.connect.ReadRecordsRequestUsingIds; import android.health.connect.RecordIdFilter; import android.health.connect.TimeInstantRangeFilter; import android.health.connect.UpdateDataOriginPriorityOrderRequest; import android.health.connect.changelog.ChangeLogsResponse; +import android.health.connect.datatypes.AggregationType; import android.health.connect.datatypes.DataOrigin; +import android.health.connect.datatypes.ExerciseSessionRecord; import android.health.connect.datatypes.HeartRateRecord; import android.health.connect.datatypes.Metadata; import android.health.connect.datatypes.Record; @@ -87,9 +93,12 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -133,9 +142,33 @@ public class HealthConnectDeviceTest { false, "CtsHealthConnectTestAppWithDataManagePermission.apk"); + private static final String STEPS_1000_CLIENT_ID = "client-id-1"; + private static final String STEPS_2000_CLIENT_ID = "client-id-2"; + private static final StepsRecord STEPS_1000 = + getStepsRecord( + /* stepCount= */ 1000, + /* startTime= */ Instant.now().minus(2, ChronoUnit.HOURS), + /* durationInHours= */ 1, + STEPS_1000_CLIENT_ID); + private static final StepsRecord STEPS_2000 = + getStepsRecord( + /* stepCount= */ 2000, + /* startTime= */ Instant.now().minus(4, ChronoUnit.HOURS), + /* durationInHours= */ 1, + STEPS_2000_CLIENT_ID); + + private static final AggregationType<Long> WRITE_ONLY_PERM_AGGREGATION_STEPS_TOTAL = + STEPS_COUNT_TOTAL; + + private static final AggregationType<Long> READ_PERM_AGGREGATION_EXERCISE_DURATION_TOTAL = + EXERCISE_DURATION_TOTAL; + + private Context mContext; + @Before public void setUp() { Assume.assumeFalse(hasSystemFeature(AUTOMOTIVE_FEATURE)); + mContext = ApplicationProvider.getApplicationContext(); } @After @@ -193,16 +226,18 @@ public class HealthConnectDeviceTest { (List<TestUtils.RecordTypeAndRecordIds>) bundle.getSerializable(RECORD_IDS); for (TestUtils.RecordTypeAndRecordIds recordTypeAndRecordIds : listOfRecordIdsAndClass) { + Class<? extends Record> recordType = + (Class<? extends Record>) Class.forName(recordTypeAndRecordIds.getRecordType()); + if (!recordType.equals(ExerciseSessionRecord.class)) { + // skip other record types since we don't have read permissions for these. + continue; + } List<Record> records = (List<Record>) readRecords( - new ReadRecordsRequestUsingFilters.Builder<>( - (Class<? extends Record>) - Class.forName( - recordTypeAndRecordIds - .getRecordType())) + new ReadRecordsRequestUsingFilters.Builder<>(recordType) .build()); - + assertThat(records).isNotEmpty(); for (Record record : records) { assertThat(record.getMetadata().getDataOrigin().getPackageName()) .isEqualTo(APP_A_WITH_READ_WRITE_PERMS.getPackageName()); @@ -211,7 +246,7 @@ public class HealthConnectDeviceTest { } @Test - public void testAppWithWritePermsOnlyCanReadItsOwnEntry() throws Exception { + public void testAppWithWritePermsOnly_readOwnData_success() throws Exception { Bundle bundle = insertRecordAs(APP_WITH_WRITE_PERMS_ONLY); assertThat(bundle.getBoolean(SUCCESS)).isTrue(); @@ -223,12 +258,17 @@ public class HealthConnectDeviceTest { recordClassesToRead.add(recordTypeAndRecordIds.getRecordType()); } - bundle = readRecordsAs(APP_WITH_WRITE_PERMS_ONLY, recordClassesToRead); - assertThat(bundle.getInt(READ_RECORDS_SIZE)).isNotEqualTo(0); + bundle = + readRecordsAs( + APP_WITH_WRITE_PERMS_ONLY, + recordClassesToRead, + /* dataOriginFilterPackageNames= */ Optional.of( + List.of(APP_WITH_WRITE_PERMS_ONLY.getPackageName()))); + assertThat(bundle.getInt(READ_RECORDS_SIZE)).isEqualTo(listOfRecordIdsAndClass.size()); } @Test - public void testAppWithWritePermsOnlyCantReadAnotherAppEntry() throws Exception { + public void testAppWithWritePermsOnly_readDataFromAllApps_throwsError() throws Exception { Bundle bundle = insertRecordAs(APP_A_WITH_READ_WRITE_PERMS); assertThat(bundle.getBoolean(SUCCESS)).isTrue(); @@ -240,8 +280,204 @@ public class HealthConnectDeviceTest { recordClassesToRead.add(recordTypeAndRecordIds.getRecordType()); } - bundle = readRecordsAs(APP_WITH_WRITE_PERMS_ONLY, recordClassesToRead); - assertThat(bundle.getInt(READ_RECORDS_SIZE)).isEqualTo(0); + try { + bundle = + readRecordsAs( + APP_WITH_WRITE_PERMS_ONLY, + recordClassesToRead, + // empty data implies all data is requested + /* dataOriginFilterPackageNames= */ Optional.of(List.of())); + fail("Expected to fail with HealthConnectException but didn't"); + } catch (Exception e) { + assertThat(e).isInstanceOf(HealthConnectException.class); + assertThat(((HealthConnectException) e).getErrorCode()) + .isEqualTo(HealthConnectException.ERROR_SECURITY); + } + } + + @Test + public void testAppWithWritePermsOnly_readDataFromOtherApps_throwsError() throws Exception { + Bundle bundle = insertRecordAs(APP_A_WITH_READ_WRITE_PERMS); + assertThat(bundle.getBoolean(SUCCESS)).isTrue(); + + List<TestUtils.RecordTypeAndRecordIds> listOfRecordIdsAndClass = + (List<TestUtils.RecordTypeAndRecordIds>) bundle.getSerializable(RECORD_IDS); + + ArrayList<String> recordClassesToRead = new ArrayList<>(); + for (TestUtils.RecordTypeAndRecordIds recordTypeAndRecordIds : listOfRecordIdsAndClass) { + recordClassesToRead.add(recordTypeAndRecordIds.getRecordType()); + } + + try { + readRecordsAs( + APP_WITH_WRITE_PERMS_ONLY, + recordClassesToRead, + /* dataOriginFilterPackageNames= */ Optional.of( + List.of( + APP_WITH_WRITE_PERMS_ONLY.getPackageName(), + APP_A_WITH_READ_WRITE_PERMS.getPackageName()))); + fail("Expected to fail with HealthConnectException but didn't"); + } catch (Exception e) { + assertThat(e).isInstanceOf(HealthConnectException.class); + assertThat(((HealthConnectException) e).getErrorCode()) + .isEqualTo(HealthConnectException.ERROR_SECURITY); + } + } + + @Test + public void testAppWithWritePermsOnly_readDataByIdForOwnApp_success() throws Exception { + Bundle bundle = + insertStepsRecordAs(APP_A_WITH_READ_WRITE_PERMS, "01:00 PM", "03:00 PM", 1000); + assertThat(bundle.getBoolean(SUCCESS)).isTrue(); + List<Record> writtenRecords = TestUtils.insertRecords(List.of(STEPS_1000, STEPS_2000)); + List<String> recordIds = + writtenRecords.stream() + .map(record -> record.getMetadata().getId()) + .collect(Collectors.toList()); + + List<Record> readRecords = + TestUtils.readRecords( + new ReadRecordsRequestUsingIds.Builder(StepsRecord.class) + .addId(recordIds.get(0)) + .addId(recordIds.get(1)) + .build()); + + assertThat( + readRecords.stream() + .map(record -> record.getMetadata().getClientRecordId()) + .collect(Collectors.toList())) + .containsExactly(STEPS_1000_CLIENT_ID, STEPS_2000_CLIENT_ID); + } + + // TODO(b/309778116): Consider throwing an error in this case. + @Test + public void testAppWithWritePermsOnly_readDataByIdForOtherApps_filtersOutOtherAppData() + throws Exception { + Bundle bundle = + insertStepsRecordAs(APP_A_WITH_READ_WRITE_PERMS, "01:00 PM", "03:00 PM", 1000); + assertThat(bundle.getBoolean(SUCCESS)).isTrue(); + String otherAppRecordId = + ((List<TestUtils.RecordTypeAndRecordIds>) bundle.getSerializable(RECORD_IDS)) + .get(0) + .getRecordIds() + .get(0); + List<Record> writtenRecords = TestUtils.insertRecords(List.of(STEPS_1000, STEPS_2000)); + List<String> recordIds = + writtenRecords.stream() + .map(record -> record.getMetadata().getId()) + .collect(Collectors.toList()); + + List<Record> readRecords = + TestUtils.readRecords( + new ReadRecordsRequestUsingIds.Builder(StepsRecord.class) + .addId(recordIds.get(0)) + .addId(recordIds.get(1)) + .addId(otherAppRecordId) + .build()); + + assertThat( + readRecords.stream() + .map(record -> record.getMetadata().getClientRecordId()) + .collect(Collectors.toList())) + .containsExactly(STEPS_1000_CLIENT_ID, STEPS_2000_CLIENT_ID); + } + + @Test + public void testAggregateRecords_onlyWritePermissions_requestsOwnDataOnly_succeeds() + throws InterruptedException { + AggregateRecordsResponse<Long> response = + TestUtils.getAggregateResponse( + new AggregateRecordsRequest.Builder<Long>( + new TimeInstantRangeFilter.Builder() + .setStartTime(Instant.ofEpochMilli(0)) + .setEndTime(Instant.now().plus(1, ChronoUnit.DAYS)) + .build()) + .addAggregationType(WRITE_ONLY_PERM_AGGREGATION_STEPS_TOTAL) + .addDataOriginsFilter( + new DataOrigin.Builder() + .setPackageName(mContext.getPackageName()) + .build()) + .build(), + /* recordsToInsert= */ List.of(STEPS_1000, STEPS_2000)); + assertThat(response.get(WRITE_ONLY_PERM_AGGREGATION_STEPS_TOTAL)) + .isEqualTo(STEPS_1000.getCount() + STEPS_2000.getCount()); + } + + @Test + public void testAggregateRecords_onlyWritePermissions_requestsOthersData_throwsHcException() + throws InterruptedException { + try { + TestUtils.getAggregateResponse( + new AggregateRecordsRequest.Builder<Long>( + new TimeInstantRangeFilter.Builder() + .setStartTime(Instant.ofEpochMilli(0)) + .setEndTime(Instant.now().plus(1, ChronoUnit.DAYS)) + .build()) + .addAggregationType(WRITE_ONLY_PERM_AGGREGATION_STEPS_TOTAL) + .addDataOriginsFilter( + new DataOrigin.Builder() + .setPackageName(mContext.getPackageName()) + .build()) + .addDataOriginsFilter( + new DataOrigin.Builder() + .setPackageName( + APP_B_WITH_READ_WRITE_PERMS.getPackageName()) + .build()) + .build(), + /* recordsToInsert= */ List.of(STEPS_1000, STEPS_2000)); + fail("Expected to fail with HealthConnectException but didn't"); + } catch (HealthConnectException e) { + assertThat(e.getErrorCode()).isEqualTo(HealthConnectException.ERROR_SECURITY); + } + } + + @Test + public void testAggregateRecords_onlyWritePermissions_allDataRequested_throwsHcException() + throws InterruptedException { + try { + TestUtils.getAggregateResponse( + new AggregateRecordsRequest.Builder<Long>( + new TimeInstantRangeFilter.Builder() + .setStartTime(Instant.ofEpochMilli(0)) + .setEndTime(Instant.now().plus(1, ChronoUnit.DAYS)) + .build()) + .addAggregationType(WRITE_ONLY_PERM_AGGREGATION_STEPS_TOTAL) + .build(), + /* recordsToInsert= */ List.of(STEPS_1000, STEPS_2000)); + fail("Expected to fail with HealthConnectException but didn't"); + } catch (HealthConnectException e) { + assertThat(e.getErrorCode()).isEqualTo(HealthConnectException.ERROR_SECURITY); + } + } + + @Test + public void + testAggregateRecords_someReadAndWritePermissions_requestsOthersData_throwsHcException() + throws InterruptedException { + try { + TestUtils.getAggregateResponse( + new AggregateRecordsRequest.Builder<Long>( + new TimeInstantRangeFilter.Builder() + .setStartTime(Instant.ofEpochMilli(0)) + .setEndTime(Instant.now().plus(1, ChronoUnit.DAYS)) + .build()) + .addAggregationType(WRITE_ONLY_PERM_AGGREGATION_STEPS_TOTAL) + .addAggregationType(READ_PERM_AGGREGATION_EXERCISE_DURATION_TOTAL) + .addDataOriginsFilter( + new DataOrigin.Builder() + .setPackageName(mContext.getPackageName()) + .build()) + .addDataOriginsFilter( + new DataOrigin.Builder() + .setPackageName( + APP_B_WITH_READ_WRITE_PERMS.getPackageName()) + .build()) + .build(), + /* recordsToInsert= */ List.of(STEPS_1000, STEPS_2000)); + fail("Expected to fail with HealthConnectException but didn't"); + } catch (HealthConnectException e) { + assertThat(e.getErrorCode()).isEqualTo(HealthConnectException.ERROR_SECURITY); + } } @Test @@ -871,4 +1107,14 @@ public class HealthConnectDeviceTest { grantPermission(APP_B_WITH_READ_WRITE_PERMS.getPackageName(), perm); } } + + private static StepsRecord getStepsRecord( + int stepCount, Instant startTime, int durationInHours, String clientId) { + return new StepsRecord.Builder( + new Metadata.Builder().setClientRecordId(clientId).build(), + startTime, + startTime.plus(durationInHours, ChronoUnit.HOURS), + stepCount) + .build(); + } } diff --git a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/dailyjob/DailyDeleteAccessLogTest.java b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/dailyjob/DailyDeleteAccessLogTest.java new file mode 100644 index 00000000..7d29f050 --- /dev/null +++ b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/dailyjob/DailyDeleteAccessLogTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.healthconnect.cts.dailyjob; + +import static android.healthconnect.cts.HostSideTestUtil.DAILY_LOG_TESTS_ACTIVITY; +import static android.healthconnect.cts.HostSideTestUtil.clearData; +import static android.healthconnect.cts.HostSideTestUtil.increaseDeviceTimeByDays; +import static android.healthconnect.cts.HostSideTestUtil.isHardwareSupported; +import static android.healthconnect.cts.HostSideTestUtil.resetTime; +import static android.healthconnect.cts.HostSideTestUtil.triggerDailyJob; +import static android.healthconnect.cts.HostSideTestUtil.triggerTestInTestApp; + +import static com.google.common.truth.Truth.assertThat; + +import com.android.tradefed.build.IBuildInfo; +import com.android.tradefed.testtype.DeviceTestCase; +import com.android.tradefed.testtype.IBuildReceiver; + +import java.time.Instant; + +public class DailyDeleteAccessLogTest extends DeviceTestCase implements IBuildReceiver { + private IBuildInfo mCtsBuild; + private Instant mTestStartTime; + private Instant mDeviceStartTime; + + @Override + protected void setUp() throws Exception { + if (!isHardwareSupported(getDevice())) { + return; + } + super.setUp(); + mTestStartTime = Instant.now(); + mDeviceStartTime = Instant.ofEpochMilli(getDevice().getDeviceDate()); + assertThat(mCtsBuild).isNotNull(); + clearData(getDevice()); + } + + @Override + protected void tearDown() throws Exception { + clearData(getDevice()); + resetTime(getDevice(), mTestStartTime, mDeviceStartTime); + super.tearDown(); + } + + @Override + public void setBuild(IBuildInfo buildInfo) { + mCtsBuild = buildInfo; + } + + public void testAccessLogsAreDeleted() throws Exception { + if (!isHardwareSupported(getDevice())) { + return; + } + + triggerTestInTestApp(getDevice(), DAILY_LOG_TESTS_ACTIVITY, "testInsertRecordsSucceed"); + triggerTestInTestApp( + getDevice(), DAILY_LOG_TESTS_ACTIVITY, "testHealthConnectAccessLogsEqualsOne"); + + increaseDeviceTimeByDays(getDevice(), 5); + triggerTestInTestApp(getDevice(), DAILY_LOG_TESTS_ACTIVITY, "testInsertRecordsSucceed"); + triggerTestInTestApp( + getDevice(), DAILY_LOG_TESTS_ACTIVITY, "testHealthConnectAccessLogsEqualsTwo"); + + // Only the first access log should have been deleted after 5 days. + increaseDeviceTimeByDays(getDevice(), 5); + triggerDailyJob(getDevice()); + triggerTestInTestApp( + getDevice(), DAILY_LOG_TESTS_ACTIVITY, "testHealthConnectAccessLogsEqualsOne"); + + // The other access log should also be deleted after 5 days. + increaseDeviceTimeByDays(getDevice(), 5); + triggerDailyJob(getDevice()); + triggerTestInTestApp( + getDevice(), DAILY_LOG_TESTS_ACTIVITY, "testHealthConnectAccessLogsEqualsZero"); + } +} diff --git a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectDailyLogsStatsTests.java b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectDailyLogsStatsTests.java index b27af1e1..c2c26a41 100644 --- a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectDailyLogsStatsTests.java +++ b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectDailyLogsStatsTests.java @@ -16,7 +16,7 @@ package android.healthconnect.cts.logging; -import static android.healthconnect.cts.logging.HostSideTestsUtils.isHardwareSupported; +import static android.healthconnect.cts.HostSideTestUtil.isHardwareSupported; import static com.google.common.truth.Truth.assertThat; @@ -51,6 +51,9 @@ public class HealthConnectDailyLogsStatsTests extends DeviceTestCase implements private static final String DAILY_LOG_TESTS_ACTIVITY = ".DailyLogsTests"; private static final String HEALTH_CONNECT_SERVICE_LOG_TESTS_ACTIVITY = ".HealthConnectServiceLogsTests"; + public static final String ENABLE_RATE_LIMITER_FLAG = "enable_rate_limiter"; + public static final String NAMESPACE_HEALTH_FITNESS = "health_fitness"; + private String mRateLimiterFeatureFlagDefaultValue; private IBuildInfo mCtsBuild; private Instant mTestStartTime; private Instant mTestStartTimeOnDevice; @@ -63,6 +66,8 @@ public class HealthConnectDailyLogsStatsTests extends DeviceTestCase implements super.setUp(); assertThat(mCtsBuild).isNotNull(); assertThat(isHardwareSupported(getDevice())).isTrue(); + // TODO(b/313055175): Do not disable rate limiting once b/300238889 is resolved. + setupRateLimitingFeatureFlag(); mTestStartTime = Instant.now(); mTestStartTimeOnDevice = Instant.ofEpochMilli(getDevice().getDeviceDate()); ConfigUtils.removeConfig(getDevice()); @@ -74,6 +79,8 @@ public class HealthConnectDailyLogsStatsTests extends DeviceTestCase implements @Override protected void tearDown() throws Exception { + // TODO(b/313055175): Do not disable rate limiting once b/300238889 is resolved. + restoreRateLimitingFeatureFlag(); ConfigUtils.removeConfig(getDevice()); ReportUtils.clearReports(getDevice()); clearData(); @@ -115,8 +122,7 @@ public class HealthConnectDailyLogsStatsTests extends DeviceTestCase implements new int[] {ApiExtensionAtoms.HEALTH_CONNECT_STORAGE_STATS_FIELD_NUMBER}); List<StatsLog.EventMetricData> data = - getEventMetricDataList( - /* testName= */ "testHealthConnectDatabaseStats", NUMBER_OF_RETRIES); + getEventMetricDataList("testInsertRecordsSucceed", NUMBER_OF_RETRIES); assertThat(data.size()).isAtLeast(1); HealthConnectStorageStats atom = data.get(0).getAtom().getExtension(ApiExtensionAtoms.healthConnectStorageStats); @@ -353,4 +359,28 @@ public class HealthConnectDailyLogsStatsTests extends DeviceTestCase implements + " --user_should_confirm_time false --elapsed_realtime 0"); getDevice().executeShellCommand("am broadcast -a android.intent.action.TIME_SET"); } + + private void setupRateLimitingFeatureFlag() throws Exception { + // Store default value of the flag on device for teardown. + mRateLimiterFeatureFlagDefaultValue = + DeviceUtils.getDeviceConfigFeature( + getDevice(), NAMESPACE_HEALTH_FITNESS, ENABLE_RATE_LIMITER_FLAG); + + DeviceUtils.putDeviceConfigFeature( + getDevice(), NAMESPACE_HEALTH_FITNESS, ENABLE_RATE_LIMITER_FLAG, "false"); + } + + private void restoreRateLimitingFeatureFlag() throws Exception { + if (mRateLimiterFeatureFlagDefaultValue == null + || mRateLimiterFeatureFlagDefaultValue.equals("null")) { + DeviceUtils.deleteDeviceConfigFeature( + getDevice(), NAMESPACE_HEALTH_FITNESS, ENABLE_RATE_LIMITER_FLAG); + } else { + DeviceUtils.putDeviceConfigFeature( + getDevice(), + NAMESPACE_HEALTH_FITNESS, + ENABLE_RATE_LIMITER_FLAG, + mRateLimiterFeatureFlagDefaultValue); + } + } } diff --git a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectServiceStatsTests.java b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectServiceStatsTests.java index 59b51e63..12846ae8 100644 --- a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectServiceStatsTests.java +++ b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectServiceStatsTests.java @@ -16,7 +16,7 @@ package android.healthconnect.cts.logging; -import static android.healthconnect.cts.logging.HostSideTestsUtils.isHardwareSupported; +import static android.healthconnect.cts.HostSideTestUtil.isHardwareSupported; import static com.google.common.truth.Truth.assertThat; diff --git a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectUiLogsTests.kt b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectUiLogsTests.kt index 9c210844..22091373 100644 --- a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectUiLogsTests.kt +++ b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HealthConnectUiLogsTests.kt @@ -20,7 +20,7 @@ import android.cts.statsdatom.lib.AtomTestUtils import android.cts.statsdatom.lib.ConfigUtils import android.cts.statsdatom.lib.DeviceUtils import android.cts.statsdatom.lib.ReportUtils -import android.healthconnect.cts.logging.HostSideTestsUtils.isHardwareSupported +import android.healthconnect.cts.HostSideTestUtil.isHardwareSupported import android.healthfitness.ui.ElementId import android.healthfitness.ui.PageId import com.android.os.StatsLog diff --git a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HostSideTestsUtils.java b/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HostSideTestsUtils.java deleted file mode 100644 index 2c098966..00000000 --- a/tests/cts/hostsidetests/healthconnect/host/src/android/healthconnect/cts/logging/HostSideTestsUtils.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.healthconnect.cts.logging; - -import android.cts.statsdatom.lib.DeviceUtils; - -import com.android.tradefed.device.ITestDevice; - -class HostSideTestsUtils { - - private static final String FEATURE_TV = "android.hardware.type.television"; - private static final String FEATURE_EMBEDDED = "android.hardware.type.embedded"; - private static final String FEATURE_WATCH = "android.hardware.type.watch"; - private static final String FEATURE_LEANBACK = "android.software.leanback"; - private static final String FEATURE_AUTOMOTIVE = "android.hardware.type.automotive"; - - public static boolean isHardwareSupported(ITestDevice device) { - // These UI tests are not optimised for Watches, TVs, Auto; - // IoT devices do not have a UI to run these UI tests - try { - return !DeviceUtils.hasFeature(device, FEATURE_TV) - && !DeviceUtils.hasFeature(device, FEATURE_EMBEDDED) - && !DeviceUtils.hasFeature(device, FEATURE_WATCH) - && !DeviceUtils.hasFeature(device, FEATURE_LEANBACK) - && !DeviceUtils.hasFeature(device, FEATURE_AUTOMOTIVE); - } catch (Exception e) { - return false; - } - } -} diff --git a/tests/cts/hostsidetests/healthconnect/host/src/util/HostSideTestUtil.java b/tests/cts/hostsidetests/healthconnect/host/src/util/HostSideTestUtil.java new file mode 100644 index 00000000..b1e1ea3d --- /dev/null +++ b/tests/cts/hostsidetests/healthconnect/host/src/util/HostSideTestUtil.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.healthconnect.cts; + +import android.cts.statsdatom.lib.AtomTestUtils; +import android.cts.statsdatom.lib.DeviceUtils; + +import com.android.tradefed.device.DeviceNotAvailableException; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.util.CommandStatus; +import com.android.tradefed.util.RunUtil; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +public class HostSideTestUtil { + + public static final String TEST_APP_PKG_NAME = "android.healthconnect.cts.testhelper"; + public static final String DAILY_LOG_TESTS_ACTIVITY = ".DailyLogsTests"; + private static final int NUMBER_OF_RETRIES = 10; + + private static final String FEATURE_TV = "android.hardware.type.television"; + private static final String FEATURE_EMBEDDED = "android.hardware.type.embedded"; + private static final String FEATURE_WATCH = "android.hardware.type.watch"; + private static final String FEATURE_LEANBACK = "android.software.leanback"; + private static final String FEATURE_AUTOMOTIVE = "android.hardware.type.automotive"; + + /** Clears all data on the device, including access logs. */ + public static void clearData(ITestDevice device) throws Exception { + triggerTestInTestApp(device, DAILY_LOG_TESTS_ACTIVITY, "deleteAllRecordsAddedForTest"); + // Next two lines will delete newly added Access Logs as all access logs over 7 days are + // deleted by the AutoDeleteService which is run by the daily job. + increaseDeviceTimeByDays(device, 10); + triggerDailyJob(device); + } + + /** Triggers a test on the device with the given className and testName. */ + public static void triggerTestInTestApp(ITestDevice device, String className, String testName) + throws Exception { + + if (testName != null) { + DeviceUtils.runDeviceTests(device, TEST_APP_PKG_NAME, className, testName); + } + } + + /** Increases the device clock by the given numberOfDays. */ + public static void increaseDeviceTimeByDays(ITestDevice device, int numberOfDays) + throws DeviceNotAvailableException { + Instant deviceDate = Instant.ofEpochMilli(device.getDeviceDate()); + + device.setDate(Date.from(deviceDate.plus(numberOfDays, ChronoUnit.DAYS))); + device.executeShellCommand( + "cmd time_detector set_time_state_for_tests --unix_epoch_time " + + deviceDate.plus(numberOfDays, ChronoUnit.DAYS).toEpochMilli() + + " --user_should_confirm_time false --elapsed_realtime 0"); + + device.executeShellCommand("am broadcast -a android.intent.action.TIME_SET"); + } + + /** Reset device time to revert all changes made during the test. */ + public static void resetTime(ITestDevice device, Instant testStartTime, Instant deviceStartTime) + throws DeviceNotAvailableException { + long timeDiff = Duration.between(testStartTime, Instant.now()).toMillis(); + + device.executeShellCommand( + "cmd time_detector set_time_state_for_tests --unix_epoch_time " + + deviceStartTime.plusMillis(timeDiff).toEpochMilli() + + " --user_should_confirm_time false --elapsed_realtime 0"); + device.executeShellCommand("am broadcast -a android.intent.action.TIME_SET"); + } + + /** Triggers the Health Connect daily job. */ + public static void triggerDailyJob(ITestDevice device) throws Exception { + + // There are multiple instances of HealthConnectDailyService. This command finds the one + // that needs to be triggered for this test using the job param 'hc_daily_job'. + String output = + device.executeShellCommand( + "dumpsys jobscheduler | grep -m1 -A0 -B10 \"hc_daily_job\""); + int indexOfStart = output.indexOf("/") + 1; + String jobId = output.substring(indexOfStart, output.indexOf(":", indexOfStart)); + String jobExecutionCommand = + "cmd jobscheduler run --namespace HEALTH_CONNECT_DAILY_JOB -f android " + jobId; + + executeJob(device, jobExecutionCommand, NUMBER_OF_RETRIES); + RunUtil.getDefault().sleep(AtomTestUtils.WAIT_TIME_LONG); + } + + private static void executeJob(ITestDevice device, String jobExecutionCommand, int retry) + throws DeviceNotAvailableException, RuntimeException { + if (retry == 0) { + throw new RuntimeException("Could not execute job"); + } + if (device.executeShellV2Command(jobExecutionCommand).getStatus() + != CommandStatus.SUCCESS) { + executeJob(device, jobExecutionCommand, retry - 1); + } + } + + /** Checks if the hardware supports Health Connect. */ + public static boolean isHardwareSupported(ITestDevice device) { + // These UI tests are not optimised for Watches, TVs, Auto; + // IoT devices do not have a UI to run these UI tests + try { + return !DeviceUtils.hasFeature(device, FEATURE_TV) + && !DeviceUtils.hasFeature(device, FEATURE_EMBEDDED) + && !DeviceUtils.hasFeature(device, FEATURE_WATCH) + && !DeviceUtils.hasFeature(device, FEATURE_LEANBACK) + && !DeviceUtils.hasFeature(device, FEATURE_AUTOMOTIVE); + } catch (Exception e) { + return false; + } + } +} diff --git a/tests/cts/hostsidetests/healthconnect/libs/HealthConnectTestLib/src/android/healthconnect/cts/lib/MultiAppTestUtils.java b/tests/cts/hostsidetests/healthconnect/libs/HealthConnectTestLib/src/android/healthconnect/cts/lib/MultiAppTestUtils.java index 51903f3a..fd0b3a2c 100644 --- a/tests/cts/hostsidetests/healthconnect/libs/HealthConnectTestLib/src/android/healthconnect/cts/lib/MultiAppTestUtils.java +++ b/tests/cts/hostsidetests/healthconnect/libs/HealthConnectTestLib/src/android/healthconnect/cts/lib/MultiAppTestUtils.java @@ -31,6 +31,7 @@ import com.android.cts.install.lib.TestApp; import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -47,6 +48,9 @@ public class MultiAppTestUtils { public static final String READ_RECORDS_SIZE = "android.healthconnect.cts.readRecordsNumber"; public static final String READ_USING_DATA_ORIGIN_FILTERS = "android.healthconnect.cts.readUsingDataOriginFilters"; + + public static final String DATA_ORIGIN_FILTER_PACKAGE_NAMES = + "android.healthconnect.cts.dataOriginFilterPackageNames"; public static final String READ_RECORD_CLASS_NAME = "android.healthconnect.cts.readRecordsClass"; public static final String READ_CHANGE_LOGS_QUERY = "android.healthconnect.cts.readChangeLogs"; @@ -125,10 +129,24 @@ public class MultiAppTestUtils { public static Bundle readRecordsAs(TestApp testApp, ArrayList<String> recordClassesToRead) throws Exception { + return readRecordsAs( + testApp, recordClassesToRead, /* dataOriginFilterPackageNames= */ Optional.empty()); + } + + public static Bundle readRecordsAs( + TestApp testApp, + ArrayList<String> recordClassesToRead, + Optional<List<String>> dataOriginFilterPackageNames) + throws Exception { Bundle bundle = new Bundle(); bundle.putString(QUERY_TYPE, READ_RECORDS_QUERY); bundle.putStringArrayList(READ_RECORD_CLASS_NAME, recordClassesToRead); - + if (!dataOriginFilterPackageNames.isEmpty()) { + ArrayList<String> dataOrigins = new ArrayList<>(); + dataOrigins.addAll(dataOriginFilterPackageNames.get()); + bundle.putBoolean(READ_USING_DATA_ORIGIN_FILTERS, true); + bundle.putStringArrayList(DATA_ORIGIN_FILTER_PACKAGE_NAMES, dataOrigins); + } return getFromTestApp(testApp, bundle); } diff --git a/tests/cts/src/android/healthconnect/cts/HealthConnectManagerTest.java b/tests/cts/src/android/healthconnect/cts/HealthConnectManagerTest.java index 89532755..ae924713 100644 --- a/tests/cts/src/android/healthconnect/cts/HealthConnectManagerTest.java +++ b/tests/cts/src/android/healthconnect/cts/HealthConnectManagerTest.java @@ -40,10 +40,17 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.DAYS; +import static java.time.temporal.ChronoUnit.HOURS; +import static java.time.temporal.ChronoUnit.MINUTES; + import android.Manifest; import android.app.UiAutomation; import android.content.Context; +import android.health.connect.AggregateRecordsGroupedByDurationResponse; import android.health.connect.AggregateRecordsRequest; +import android.health.connect.AggregateRecordsResponse; import android.health.connect.DeleteUsingFiltersRequest; import android.health.connect.HealthConnectDataState; import android.health.connect.HealthConnectException; @@ -99,7 +106,6 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.Period; import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -656,7 +662,7 @@ public class HealthConnectManagerTest { @Test public void testReadRecords_multiplePagesSameStartTimeRecords_paginatedCorrectly() throws Exception { - Instant startTime = Instant.now().minus(1, ChronoUnit.DAYS); + Instant startTime = Instant.now().minus(1, DAYS); insertRecords( List.of( @@ -712,6 +718,60 @@ public class HealthConnectManagerTest { } @Test + public void testAggregation_stepsCountTotal_acrossDST_works() throws Exception { + ZoneOffset utcPlusOne = ZoneOffset.ofTotalSeconds(UTC.getTotalSeconds() + 3600); + + Instant midNight = Instant.now().truncatedTo(DAYS); + + Instant t0057 = midNight.plus(57, MINUTES); + Instant t0058 = midNight.plus(58, MINUTES); + Instant t0059 = midNight.plus(59, MINUTES); + Instant t0100 = midNight.plus(1, HOURS); + Instant t0300 = midNight.plus(3, HOURS); + Instant t0400 = midNight.plus(4, HOURS); + + List<Record> records = + Arrays.asList( + getStepsRecord( + t0057, utcPlusOne, t0058, utcPlusOne, 12), // 1:57-1:58 in test + // this will be removed by the workaround + getStepsRecord(t0059, utcPlusOne, t0100, UTC, 16), // 1:59-1:00 in test + getStepsRecord(t0300, UTC, t0400, UTC, 250)); + TestUtils.insertRecords(records); + LocalDateTime startOfToday = LocalDateTime.now(UTC).truncatedTo(DAYS); + AggregateRecordsRequest<Long> aggregateRecordsRequest = + new AggregateRecordsRequest.Builder<Long>( + new LocalTimeRangeFilter.Builder() + .setStartTime(startOfToday.plus(1, HOURS)) + .setEndTime(startOfToday.plus(4, HOURS)) + .build()) + .addAggregationType(STEPS_COUNT_TOTAL) + .build(); + assertThat(aggregateRecordsRequest.getAggregationTypes()).isNotNull(); + assertThat(aggregateRecordsRequest.getTimeRangeFilter()).isNotNull(); + assertThat(aggregateRecordsRequest.getDataOriginsFilters()).isNotNull(); + + AggregateRecordsResponse<Long> aggregateResponse = + TestUtils.getAggregateResponse(aggregateRecordsRequest); + assertThat(aggregateResponse.get(STEPS_COUNT_TOTAL)).isEqualTo(262); + + List<AggregateRecordsGroupedByDurationResponse<Long>> groupByResponse = + TestUtils.getAggregateResponseGroupByDuration( + aggregateRecordsRequest, Duration.ofHours(1)); + assertThat(groupByResponse.get(0).getStartTime()).isEqualTo(midNight); + assertThat(groupByResponse.get(0).getEndTime()).isEqualTo(t0100); + assertThat(groupByResponse.get(0).getZoneOffset(STEPS_COUNT_TOTAL)).isEqualTo(utcPlusOne); + assertThat(groupByResponse.get(0).get(STEPS_COUNT_TOTAL)).isEqualTo(12); + assertThat(groupByResponse.get(1).getStartTime()).isEqualTo(t0100.plus(1, HOURS)); + assertThat(groupByResponse.get(1).getEndTime()).isEqualTo(t0300); + assertThat(groupByResponse.get(1).getZoneOffset(STEPS_COUNT_TOTAL)).isNull(); + assertThat(groupByResponse.get(2).getStartTime()).isEqualTo(t0300); + assertThat(groupByResponse.get(2).getEndTime()).isEqualTo(t0400); + assertThat(groupByResponse.get(2).getZoneOffset(STEPS_COUNT_TOTAL)).isEqualTo(UTC); + assertThat(groupByResponse.get(2).get(STEPS_COUNT_TOTAL)).isEqualTo(250); + } + + @Test public void testAutoDeleteApis() throws InterruptedException { UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); @@ -1652,7 +1712,7 @@ public class HealthConnectManagerTest { AggregateRecordsRequest<Long> aggregateRecordsRequest = new AggregateRecordsRequest.Builder<Long>( new TimeInstantRangeFilter.Builder() - .setStartTime(Instant.now().minus(3, ChronoUnit.DAYS)) + .setStartTime(Instant.now().minus(3, DAYS)) .setEndTime(Instant.now()) .build()) .addAggregationType(STEPS_COUNT_TOTAL) @@ -1682,9 +1742,8 @@ public class HealthConnectManagerTest { TestUtils.getAggregateResponseGroupByPeriod( new AggregateRecordsRequest.Builder<Long>( new LocalTimeRangeFilter.Builder() - .setStartTime( - LocalDateTime.now(ZoneOffset.UTC).minusDays(2)) - .setEndTime(LocalDateTime.now(ZoneOffset.UTC)) + .setStartTime(LocalDateTime.now(UTC).minusDays(2)) + .setEndTime(LocalDateTime.now(UTC)) .build()) .addAggregationType(STEPS_COUNT_TOTAL) .build(), @@ -1895,7 +1954,7 @@ public class HealthConnectManagerTest { .anyMatch(list -> !list.isEmpty()); } - private void deleteAllStagedRemoteData() + private static void deleteAllStagedRemoteData() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { try { Context context = ApplicationProvider.getApplicationContext(); @@ -1916,7 +1975,7 @@ public class HealthConnectManagerTest { } } - private void verifyRecordTypeResponse( + private static void verifyRecordTypeResponse( Map<Class<? extends Record>, RecordTypeInfoResponse> responses, HashMap<Class<? extends Record>, TestUtils.RecordTypeInfoTestResponse> expectedResponse) { @@ -1940,14 +1999,14 @@ public class HealthConnectManagerTest { }); } - private List<Record> getTestRecords() { + private static List<Record> getTestRecords() { return Arrays.asList( getStepsRecord(/*clientRecordId=*/ null, /*packageName=*/ ""), getHeartRateRecord(), getBasalMetabolicRateRecord()); } - private Record setTestRecordId(Record record, String id) { + private static Record setTestRecordId(Record record, String id) { Metadata metadata = record.getMetadata(); Metadata metadataWithId = new Metadata.Builder() @@ -2018,7 +2077,7 @@ public class HealthConnectManagerTest { return readRecords; } - private StepsRecord getStepsRecord(String clientRecordId, String packageName) { + private static StepsRecord getStepsRecord(String clientRecordId, String packageName) { return getStepsRecord( clientRecordId, packageName, @@ -2027,7 +2086,7 @@ public class HealthConnectManagerTest { Instant.now().plusMillis(1000)); } - private StepsRecord getStepsRecord( + private static StepsRecord getStepsRecord( String clientRecordId, String packageName, int count, @@ -2044,7 +2103,24 @@ public class HealthConnectManagerTest { .build(); } - private HeartRateRecord getHeartRateRecord() { + private static StepsRecord getStepsRecord( + Instant startTime, + ZoneOffset startOffset, + Instant endTime, + ZoneOffset endOffset, + int count) { + StepsRecord.Builder builder = + new StepsRecord.Builder(new Metadata.Builder().build(), startTime, endTime, count); + if (startOffset != null) { + builder.setStartZoneOffset(startOffset); + } + if (endOffset != null) { + builder.setEndZoneOffset(endOffset); + } + return builder.build(); + } + + private static HeartRateRecord getHeartRateRecord() { HeartRateRecord.HeartRateSample heartRateSample = new HeartRateRecord.HeartRateSample(72, Instant.now().plusMillis(100)); ArrayList<HeartRateRecord.HeartRateSample> heartRateSamples = new ArrayList<>(); @@ -2062,12 +2138,12 @@ public class HealthConnectManagerTest { .build(); } - private BasalMetabolicRateRecord getBasalMetabolicRateRecord() { + private static BasalMetabolicRateRecord getBasalMetabolicRateRecord() { return getBasalMetabolicRateRecord( /*clientRecordId=*/ null, /*bmr=*/ Power.fromWatts(100.0), Instant.now()); } - private BasalMetabolicRateRecord getBasalMetabolicRateRecord( + private static BasalMetabolicRateRecord getBasalMetabolicRateRecord( String clientRecordId, Power bmr, Instant time) { Device device = getPhoneDevice(); DataOrigin dataOrigin = getDataOrigin(); @@ -2079,7 +2155,7 @@ public class HealthConnectManagerTest { return new BasalMetabolicRateRecord.Builder(testMetadataBuilder.build(), time, bmr).build(); } - private HydrationRecord getHydrationRecord( + private static HydrationRecord getHydrationRecord( String clientRecordId, Instant startTime, Instant endTime, Volume volume) { Device device = getPhoneDevice(); DataOrigin dataOrigin = getDataOrigin(); @@ -2092,7 +2168,7 @@ public class HealthConnectManagerTest { .build(); } - private NutritionRecord getNutritionRecord( + private static NutritionRecord getNutritionRecord( String clientRecordId, Instant startTime, Instant endTime, Mass protein) { Device device = getPhoneDevice(); DataOrigin dataOrigin = getDataOrigin(); diff --git a/tests/cts/src/android/healthconnect/cts/SharedMemoryTest.java b/tests/cts/src/android/healthconnect/cts/SharedMemoryTest.java new file mode 100644 index 00000000..04202f0f --- /dev/null +++ b/tests/cts/src/android/healthconnect/cts/SharedMemoryTest.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.healthconnect.cts; + +import static android.healthconnect.cts.utils.TestUtils.deleteAllStagedRemoteData; +import static android.healthconnect.cts.utils.TestUtils.deleteRecords; +import static android.healthconnect.cts.utils.TestUtils.insertRecords; +import static android.healthconnect.cts.utils.TestUtils.readAllRecords; +import static android.healthconnect.cts.utils.TestUtils.readRecords; + +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static java.util.Comparator.comparing; + +import android.health.connect.ReadRecordsRequestUsingFilters; +import android.health.connect.changelog.ChangeLogTokenRequest; +import android.health.connect.changelog.ChangeLogsRequest; +import android.health.connect.changelog.ChangeLogsResponse; +import android.health.connect.changelog.ChangeLogsResponse.DeletedLog; +import android.health.connect.datatypes.DataOrigin; +import android.health.connect.datatypes.HeightRecord; +import android.health.connect.datatypes.InstantRecord; +import android.health.connect.datatypes.Metadata; +import android.health.connect.datatypes.Record; +import android.health.connect.datatypes.WeightRecord; +import android.health.connect.datatypes.units.Length; +import android.health.connect.datatypes.units.Mass; +import android.healthconnect.cts.utils.TestUtils; + +import androidx.test.runner.AndroidJUnit4; + +import com.google.common.truth.Correspondence; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class SharedMemoryTest { + + @Before + public void before() { + deleteAllStagedRemoteData(); + } + + @After + public void after() { + deleteAllStagedRemoteData(); + } + + @Test + public void insertRecordsAndReadRecords_viaSharedMemory_recordsEqual() throws Exception { + DataOrigin dataOrigin = + new DataOrigin.Builder() + .setPackageName(getApplicationContext().getPackageName()) + .build(); + + Metadata metadata = new Metadata.Builder().setDataOrigin(dataOrigin).build(); + int recordCount = 5000; + List<HeightRecord> records = new ArrayList<>(recordCount); + Instant now = Instant.now(); + + for (int i = 0; i < recordCount; i++) { + records.add( + new HeightRecord.Builder( + metadata, + now.minusMillis(i), + Length.fromMeters(3.0 * i / recordCount)) + .build()); + } + + insertRecords(records); + + List<HeightRecord> readRecords = + readRecords( + new ReadRecordsRequestUsingFilters.Builder<>(HeightRecord.class) + .setPageSize(records.size()) + .build()); + + assertWithMessage("Record list sizes do not match") + .that(readRecords.size()) + .isEqualTo(recordCount); + + readRecords.sort(comparing(InstantRecord::getTime).reversed()); + + for (int i = 0; i < recordCount; i++) { + assertThat(readRecords.get(i).getHeight()).isEqualTo(records.get(i).getHeight()); + } + } + + @Test + public void getChangeLogs_viaSharedMemory_recordsEquals() throws Exception { + DataOrigin dataOrigin = + new DataOrigin.Builder() + .setPackageName(getApplicationContext().getPackageName()) + .build(); + Metadata metadata = new Metadata.Builder().setDataOrigin(dataOrigin).build(); + + int recordCount = 5000; + List<HeightRecord> heightRecords = new ArrayList<>(recordCount); + List<WeightRecord> weightRecords = new ArrayList<>(recordCount); + Instant now = Instant.now(); + for (int i = 0; i < recordCount; i++) { + Instant time = now.minusMillis(i); + heightRecords.add( + new HeightRecord.Builder( + metadata, time, Length.fromMeters(3.0 * i / recordCount)) + .build()); + weightRecords.add( + new WeightRecord.Builder(metadata, time, Mass.fromGrams(1000.0 * 70.0 + i * 10)) + .build()); + } + + String changeLogToken = + TestUtils.getChangeLogToken(new ChangeLogTokenRequest.Builder().build()).getToken(); + insertRecords(heightRecords); + heightRecords = readAllRecords(HeightRecord.class); + deleteRecords(heightRecords); + insertRecords(weightRecords); + weightRecords = readAllRecords(WeightRecord.class); + + List<DeletedLog> deletedLogs = new ArrayList<>(); + List<Record> upsertedRecords = new ArrayList<>(); + + ChangeLogsResponse changeLogsResponse = + TestUtils.getChangeLogs(new ChangeLogsRequest.Builder(changeLogToken).build()); + while (true) { + upsertedRecords.addAll(changeLogsResponse.getUpsertedRecords()); + deletedLogs.addAll(changeLogsResponse.getDeletedLogs()); + if (!changeLogsResponse.hasMorePages()) { + break; + } + changeLogToken = changeLogsResponse.getNextChangesToken(); + changeLogsResponse = + TestUtils.getChangeLogs(new ChangeLogsRequest.Builder(changeLogToken).build()); + } + + assertThat(deletedLogs).hasSize(recordCount); + assertThat(upsertedRecords).hasSize(recordCount); + assertThat(deletedLogs) + .comparingElementsUsing( + Correspondence.<DeletedLog, Record>from( + (deletedLog, record) -> + deletedLog + .getDeletedRecordId() + .equals(record.getMetadata().getId()), + "deleted log record id is equal to deleted record id")) + .containsExactlyElementsIn(heightRecords); + assertThat(changeLogsResponse.getUpsertedRecords()) + .containsExactlyElementsIn(weightRecords); + } +} diff --git a/tests/cts/src/android/healthconnect/cts/StepsRecordTest.java b/tests/cts/src/android/healthconnect/cts/StepsRecordTest.java index 76893a6c..29617f86 100644 --- a/tests/cts/src/android/healthconnect/cts/StepsRecordTest.java +++ b/tests/cts/src/android/healthconnect/cts/StepsRecordTest.java @@ -1366,6 +1366,71 @@ public class StepsRecordTest { } @Test + public void testAggregateDuration_differentTimeZones_correctBucketTimes() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Duration oneHour = Duration.ofHours(1); + Instant t1 = Instant.now().minus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MILLIS); + Instant t2 = t1.plus(oneHour); + Instant t3 = t2.plus(oneHour); + + Metadata metadata = + new Metadata.Builder() + .setDataOrigin( + new DataOrigin.Builder() + .setPackageName(context.getPackageName()) + .build()) + .build(); + + ZoneOffset zonePlusFive = ZoneOffset.ofHours(5); + ZoneOffset zonePlusSix = ZoneOffset.ofHours(6); + ZoneOffset zonePlusSeven = ZoneOffset.ofHours(7); + + StepsRecord rec1 = + new StepsRecord.Builder(metadata, t1, t2, /* count= */ 100) + .setStartZoneOffset(zonePlusFive) + .setEndZoneOffset(zonePlusFive) + .build(); + StepsRecord rec2 = + new StepsRecord.Builder(metadata, t2, t2, /* count= */ 300) + .setStartZoneOffset(zonePlusSix) + .setEndZoneOffset(zonePlusSeven) + .build(); + + TestUtils.insertRecords(List.of(rec1, rec2)); + + // Aggregating between [t1+5, t2+7] with 1 hour group duration + List<AggregateRecordsGroupedByDurationResponse<Long>> result = + TestUtils.getAggregateResponseGroupByDuration( + new AggregateRecordsRequest.Builder<Long>( + new LocalTimeRangeFilter.Builder() + .setStartTime( + LocalDateTime.ofInstant(t1, zonePlusFive)) + .setEndTime( + LocalDateTime.ofInstant(t2, zonePlusSeven)) + .build()) + .addAggregationType(STEPS_COUNT_TOTAL) + .build(), + oneHour); + + assertThat(result).hasSize(3); + + // Bucket #0: [t1+5, t2+5] + AggregateRecordsGroupedByDurationResponse<Long> response0 = result.get(0); + assertThat(response0.getStartTime()).isEqualTo(t1); + assertThat(response0.getEndTime()).isEqualTo(t2); + assertThat(response0.getZoneOffset(STEPS_COUNT_TOTAL)).isEqualTo(zonePlusFive); + + // Empty bucket in the middle + assertThat(result.get(1).get(STEPS_COUNT_TOTAL)).isNull(); + + // Bucket #2: [t2+6, t3+6] + AggregateRecordsGroupedByDurationResponse<Long> response2 = result.get(2); + assertThat(response2.getStartTime()).isEqualTo(t2); + assertThat(response2.getEndTime()).isEqualTo(t3); + assertThat(response2.getZoneOffset(STEPS_COUNT_TOTAL)).isEqualTo(zonePlusSix); + } + + @Test public void testAggregateDuration_withLocalDateTime() throws Exception { testAggregateDurationWithLocalTimeForZoneOffset(ZoneOffset.MIN); testAggregateDurationWithLocalTimeForZoneOffset(ZoneOffset.ofHours(-4)); @@ -1393,7 +1458,7 @@ public class StepsRecordTest { Duration.ofDays(1)); assertThat(responses).hasSize(4); - Instant groupBoundary = startTimeLocal.toInstant(ZoneOffset.UTC); + Instant groupBoundary = startTimeLocal.toInstant(offset); for (int i = 0; i < 4; i++) { assertThat(responses.get(i).get(STEPS_COUNT_TOTAL)).isEqualTo(10); assertThat(responses.get(i).getZoneOffset(STEPS_COUNT_TOTAL)).isEqualTo(offset); diff --git a/tests/cts/src/android/healthconnect/cts/WeightRecordTest.java b/tests/cts/src/android/healthconnect/cts/WeightRecordTest.java index 93981aaf..dcd090ad 100644 --- a/tests/cts/src/android/healthconnect/cts/WeightRecordTest.java +++ b/tests/cts/src/android/healthconnect/cts/WeightRecordTest.java @@ -627,6 +627,68 @@ public class WeightRecordTest { } @Test + public void testAggregateDuration_differentTimeZones_correctBucketTimes() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Duration oneHour = Duration.ofHours(1); + Instant t1 = Instant.now().minus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MILLIS); + Instant t2 = t1.plus(oneHour); + Instant t3 = t2.plus(oneHour); + + Metadata metadata = + new Metadata.Builder() + .setDataOrigin( + new DataOrigin.Builder() + .setPackageName(context.getPackageName()) + .build()) + .build(); + + ZoneOffset zonePlusFive = ZoneOffset.ofHours(5); + ZoneOffset zonePlusSix = ZoneOffset.ofHours(6); + + WeightRecord rec1 = + new WeightRecord.Builder(metadata, t1, Mass.fromGrams(50000)) + .setZoneOffset(zonePlusFive) + .build(); + WeightRecord rec2 = + new WeightRecord.Builder(metadata, t2, Mass.fromGrams(100000)) + .setZoneOffset(zonePlusSix) + .build(); + + TestUtils.insertRecords(List.of(rec1, rec2)); + + // Aggregating between [t1+5, t3+6] with 1 hour group duration + List<AggregateRecordsGroupedByDurationResponse<Mass>> result = + TestUtils.getAggregateResponseGroupByDuration( + new AggregateRecordsRequest.Builder<Mass>( + new LocalTimeRangeFilter.Builder() + .setStartTime( + LocalDateTime.ofInstant(t1, zonePlusFive)) + .setEndTime( + LocalDateTime.ofInstant(t3, zonePlusSix)) + .build()) + .addAggregationType(WEIGHT_AVG) + .build(), + oneHour); + + assertThat(result).hasSize(3); + + // Bucket #0: [t1+5, t2+5] + AggregateRecordsGroupedByDurationResponse<Mass> response0 = result.get(0); + assertThat(response0.getStartTime()).isEqualTo(t1); + assertThat(response0.getEndTime()).isEqualTo(t2); + assertThat(response0.getZoneOffset(WEIGHT_AVG)).isEqualTo(zonePlusFive); + + // Empty bucket in the middle + assertThat(result.get(1).get(WEIGHT_AVG)).isNull(); + + // Bucket #2: [t2+6, t3+6] + AggregateRecordsGroupedByDurationResponse<Mass> response2 = result.get(2); + assertThat(response2.getStartTime()).isEqualTo(t2); + assertThat(response2.getEndTime()).isEqualTo(t3); + assertThat(response2.getZoneOffset(WEIGHT_AVG)).isEqualTo(zonePlusSix); + } + + @Test public void testAggregateDuration_withLocalDateTime_responsesAnswerAndBoundariesCorrect() throws Exception { testDurationLocalTimeAggregationZoneOffset(ZoneOffset.ofHours(4)); @@ -654,7 +716,7 @@ public class WeightRecordTest { Duration.ofDays(1)); assertThat(responses).hasSize(3); - Instant groupBoundary = endTimeLocal.minusDays(3).toInstant(ZoneOffset.UTC); + Instant groupBoundary = endTimeLocal.minusDays(3).toInstant(offset); for (int i = 0; i < 3; i++) { assertThat(responses.get(i).get(WEIGHT_MAX)).isEqualTo(Mass.fromGrams(10.0)); assertThat(responses.get(i).getZoneOffset(WEIGHT_MAX)).isEqualTo(offset); diff --git a/tests/cts/src/android/healthconnect/cts/ratelimiter/RateLimiterTest.java b/tests/cts/src/android/healthconnect/cts/ratelimiter/RateLimiterTest.java index 0a2b298c..c7a2cdb6 100644 --- a/tests/cts/src/android/healthconnect/cts/ratelimiter/RateLimiterTest.java +++ b/tests/cts/src/android/healthconnect/cts/ratelimiter/RateLimiterTest.java @@ -63,7 +63,8 @@ import java.util.List; @RunWith(AndroidJUnit4.class) public class RateLimiterTest { private static final String TAG = "RateLimiterTest"; - private static final int MAX_FOREGROUND_CALL_15M = 1000; + private static final int MAX_FOREGROUND_WRITE_CALL_15M = 1000; + private static final int MAX_FOREGROUND_READ_CALL_15M = 2000; private static final Duration WINDOW_15M = Duration.ofMinutes(15); public static final String ENABLE_RATE_LIMITER_FLAG = "enable_rate_limiter"; private final UiAutomation mUiAutomation = @@ -90,7 +91,7 @@ public class RateLimiterTest { @Test public void testTryAcquireApiCallQuota_writeCallsInLimit() throws InterruptedException { - tryAcquireCallQuotaNTimesForWrite(MAX_FOREGROUND_CALL_15M); + tryAcquireCallQuotaNTimesForWrite(MAX_FOREGROUND_WRITE_CALL_15M); } @Test @@ -176,10 +177,10 @@ public class RateLimiterTest { private void exceedWriteQuota() throws InterruptedException { Instant startTime = Instant.now(); - tryAcquireCallQuotaNTimesForWrite(MAX_FOREGROUND_CALL_15M); + tryAcquireCallQuotaNTimesForWrite(MAX_FOREGROUND_WRITE_CALL_15M); Instant endTime = Instant.now(); float quotaAcquired = - getQuotaAcquired(startTime, endTime, WINDOW_15M, MAX_FOREGROUND_CALL_15M); + getQuotaAcquired(startTime, endTime, WINDOW_15M, MAX_FOREGROUND_WRITE_CALL_15M); List<Record> testRecord = List.of(TestUtils.getCompleteStepsRecord()); while (quotaAcquired > 1) { @@ -206,7 +207,7 @@ public class RateLimiterTest { tryAcquireCallQuotaNTimesForRead(testRecord, insertedRecords); Instant endTime = Instant.now(); float quotaAcquired = - getQuotaAcquired(startTime, endTime, WINDOW_15M, MAX_FOREGROUND_CALL_15M); + getQuotaAcquired(startTime, endTime, WINDOW_15M, MAX_FOREGROUND_READ_CALL_15M); while (quotaAcquired > 1) { readStepsRecordUsingIds(insertedRecords); quotaAcquired--; @@ -236,7 +237,7 @@ public class RateLimiterTest { getChangeLog(context); } - for (int i = 0; i < MAX_FOREGROUND_CALL_15M - 300; i++) { + for (int i = 0; i < MAX_FOREGROUND_READ_CALL_15M - 300; i++) { readStepsRecordUsingIds(insertedRecords); } diff --git a/tests/cts/utils/HealthConnectTestUtils/src/android/healthconnect/cts/utils/TestUtils.java b/tests/cts/utils/HealthConnectTestUtils/src/android/healthconnect/cts/utils/TestUtils.java index d3109af8..ad8a80fb 100644 --- a/tests/cts/utils/HealthConnectTestUtils/src/android/healthconnect/cts/utils/TestUtils.java +++ b/tests/cts/utils/HealthConnectTestUtils/src/android/healthconnect/cts/utils/TestUtils.java @@ -35,11 +35,11 @@ import static android.healthconnect.test.app.TestAppReceiver.EXTRA_SENDER_PACKAG import static com.android.compatibility.common.util.FeatureUtil.AUTOMOTIVE_FEATURE; import static com.android.compatibility.common.util.FeatureUtil.hasSystemFeature; -import static com.android.compatibility.common.util.SystemUtil.runShellCommand; import static com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity; import static com.google.common.truth.Truth.assertThat; +import static java.util.Collections.unmodifiableList; import static java.util.Objects.requireNonNull; import android.app.UiAutomation; @@ -61,6 +61,7 @@ import android.health.connect.HealthPermissionCategory; import android.health.connect.HealthPermissions; import android.health.connect.InsertRecordsResponse; import android.health.connect.ReadRecordsRequest; +import android.health.connect.ReadRecordsRequestUsingFilters; import android.health.connect.ReadRecordsRequestUsingIds; import android.health.connect.ReadRecordsResponse; import android.health.connect.RecordIdFilter; @@ -122,6 +123,7 @@ import android.health.connect.datatypes.WeightRecord; import android.health.connect.datatypes.WheelchairPushesRecord; import android.health.connect.datatypes.units.Length; import android.health.connect.datatypes.units.Power; +import android.health.connect.migration.MigrationEntity; import android.health.connect.migration.MigrationException; import android.healthconnect.test.app.TestAppReceiver; import android.os.Bundle; @@ -219,15 +221,24 @@ public final class TestUtils { * @param records records to insert * @return inserted records */ - public static List<Record> insertRecords(List<Record> records) throws InterruptedException { + public static List<Record> insertRecords(List<? extends Record> records) + throws InterruptedException { return insertRecords(records, ApplicationProvider.getApplicationContext()); } - public static List<Record> insertRecords(List<Record> records, Context context) + /** + * Inserts records to the database. + * + * @param records records to insert. + * @param context a {@link Context} to obtain {@link HealthConnectManager}. + * @return inserted records. + */ + public static List<Record> insertRecords(List<? extends Record> records, Context context) throws InterruptedException { HealthConnectReceiver<InsertRecordsResponse> receiver = new HealthConnectReceiver<>(); getHealthConnectManager(context) - .insertRecords(records, Executors.newSingleThreadExecutor(), receiver); + .insertRecords( + unmodifiableList(records), Executors.newSingleThreadExecutor(), receiver); List<Record> returnedRecords = receiver.getResponse().getRecords(); assertThat(returnedRecords).hasSize(records.size()); return returnedRecords; @@ -658,6 +669,28 @@ public final class TestUtils { .isNotEmpty(); } + /** Reads all records in the DB for a given {@code recordClass}. */ + public static <T extends Record> List<T> readAllRecords(Class<T> recordClass) + throws InterruptedException { + List<T> records = new ArrayList<>(); + ReadRecordsResponse<T> readRecordsResponse = + readRecordsWithPagination( + new ReadRecordsRequestUsingFilters.Builder<>(recordClass).build()); + while (true) { + records.addAll(readRecordsResponse.getRecords()); + long pageToken = readRecordsResponse.getNextPageToken(); + if (pageToken == -1) { + break; + } + readRecordsResponse = + readRecordsWithPagination( + new ReadRecordsRequestUsingFilters.Builder<>(recordClass) + .setPageToken(pageToken) + .build()); + } + return records; + } + public static <T extends Record> ReadRecordsResponse<T> readRecordsWithPagination( ReadRecordsRequest<T> request) throws InterruptedException { HealthConnectReceiver<ReadRecordsResponse<T>> receiver = new HealthConnectReceiver<>(); @@ -717,7 +750,8 @@ public final class TestUtils { receiver.verifyNoExceptionOrThrow(); } - public static void deleteRecords(List<Record> records) throws InterruptedException { + /** Helper function to delete records from the DB using HealthConnectManager. */ + public static void deleteRecords(List<? extends Record> records) throws InterruptedException { List<RecordIdFilter> recordIdFilters = records.stream() .map( @@ -802,6 +836,14 @@ public final class TestUtils { receiver.verifyNoExceptionOrThrow(); } + public static void writeMigrationData(List<MigrationEntity> entities) + throws InterruptedException { + MigrationReceiver receiver = new MigrationReceiver(); + getHealthConnectManager() + .writeMigrationData(entities, Executors.newSingleThreadExecutor(), receiver); + receiver.verifyNoExceptionOrThrow(); + } + public static void finishMigration() throws InterruptedException { MigrationReceiver receiver = new MigrationReceiver(); getHealthConnectManager().finishMigration(Executors.newSingleThreadExecutor(), receiver); @@ -1359,7 +1401,7 @@ public final class TestUtils { } public static void sendCommandToTestAppReceiver(Context context, String action) { - sendCommandToTestAppReceiver(context, action, /*extras=*/ null); + sendCommandToTestAppReceiver(context, action, /* extras= */ null); } public static void sendCommandToTestAppReceiver(Context context, String action, Bundle extras) { diff --git a/tests/integrationtests/src/android/healthconnect/tests/backgroundread/BackgroundReadTest.java b/tests/integrationtests/src/android/healthconnect/tests/backgroundread/BackgroundReadTest.java index 3893bdea..53a0eb69 100644 --- a/tests/integrationtests/src/android/healthconnect/tests/backgroundread/BackgroundReadTest.java +++ b/tests/integrationtests/src/android/healthconnect/tests/backgroundread/BackgroundReadTest.java @@ -94,11 +94,8 @@ public class BackgroundReadTest { sendCommandToTestAppReceiver(mContext, ACTION_READ_RECORDS_FOR_OTHER_APP); - final Bundle result = TestReceiver.getResult(); - assertThat(result).isNotNull(); - - // Other apps' data is simply not returned when reading in background - assertThat(result.getInt(EXTRA_RECORD_COUNT)).isEqualTo(0); + assertThat(TestReceiver.getResult()).isNull(); + assertThat(TestReceiver.getErrorCode()).isEqualTo(ERROR_SECURITY); } @Test diff --git a/tests/unittests/src/android/healthconnect/RateLimiterTest.java b/tests/unittests/src/android/healthconnect/RateLimiterTest.java index d47fc751..236ac67a 100644 --- a/tests/unittests/src/android/healthconnect/RateLimiterTest.java +++ b/tests/unittests/src/android/healthconnect/RateLimiterTest.java @@ -16,90 +16,60 @@ package android.healthconnect; -import static android.health.connect.ratelimiter.RateLimiter.CHUNK_SIZE_LIMIT_IN_BYTES; -import static android.health.connect.ratelimiter.RateLimiter.RECORD_SIZE_LIMIT_IN_BYTES; - import static org.hamcrest.CoreMatchers.containsString; +import android.Manifest; +import android.app.UiAutomation; +import android.content.Context; import android.health.connect.HealthConnectException; import android.health.connect.ratelimiter.RateLimiter; import android.health.connect.ratelimiter.RateLimiter.QuotaCategory; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.modules.utils.testing.ExtendedMockitoRule; import com.android.server.healthconnect.HealthConnectDeviceConfigManager; +import com.android.server.healthconnect.TestUtils; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.Mock; +import org.mockito.quality.Strictness; import java.time.Duration; import java.time.Instant; -import java.util.HashMap; -import java.util.Map; public class RateLimiterTest { private static final int UID = 1; private static final boolean IS_IN_FOREGROUND_TRUE = true; private static final boolean IS_IN_FOREGROUND_FALSE = false; - private static final int MAX_FOREGROUND_CALL_15M = 1000; + private static final int MAX_FOREGROUND_READ_CALL_15M = 2000; private static final int MAX_BACKGROUND_CALL_15M = 1000; private static final Duration WINDOW_15M = Duration.ofMinutes(15); private static final int MEMORY_COST = 20000; + private static final UiAutomation UI_AUTOMATION = + InstrumentationRegistry.getInstrumentation().getUiAutomation(); + @Rule public ExpectedException exception = ExpectedException.none(); + @Rule + public final ExtendedMockitoRule mExtendedMockitoRule = + new ExtendedMockitoRule.Builder(this).setStrictness(Strictness.LENIENT).build(); + + @Mock Context mContext; + @Before public void setUp() { - Map<Integer, Integer> quotaBucketToMaxRollingQuotaMap = new HashMap<>(); - Map<String, Integer> quotaBucketToMaxMemoryQuotaMap = new HashMap<>(); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_READS_PER_15M_FOREGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_READS_PER_15M_BACKGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_15M_FOREGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_15M_BACKGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_READS_PER_24H_FOREGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_READS_PER_24H_BACKGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_24H_FOREGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND, - HealthConnectDeviceConfigManager - .QUOTA_BUCKET_PER_24H_BACKGROUND_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_DATA_PUSH_LIMIT_PER_APP_15M, - HealthConnectDeviceConfigManager.DATA_PUSH_LIMIT_PER_APP_15M_DEFAULT_FLAG_VALUE); - quotaBucketToMaxRollingQuotaMap.put( - RateLimiter.QuotaBucket.QUOTA_BUCKET_DATA_PUSH_LIMIT_ACROSS_APPS_15M, - HealthConnectDeviceConfigManager - .DATA_PUSH_LIMIT_ACROSS_APPS_15M_DEFAULT_FLAG_VALUE); - quotaBucketToMaxMemoryQuotaMap.put( - CHUNK_SIZE_LIMIT_IN_BYTES, - HealthConnectDeviceConfigManager.CHUNK_SIZE_LIMIT_IN_BYTES_DEFAULT_FLAG_VALUE); - quotaBucketToMaxMemoryQuotaMap.put( - RECORD_SIZE_LIMIT_IN_BYTES, - HealthConnectDeviceConfigManager.RECORD_SIZE_LIMIT_IN_BYTES_DEFAULT_FLAG_VALUE); - RateLimiter.updateMaxRollingQuotaMap(quotaBucketToMaxRollingQuotaMap); - RateLimiter.updateMemoryQuotaMap(quotaBucketToMaxMemoryQuotaMap); - RateLimiter.updateEnableRateLimiterFlag(true); + TestUtils.runWithShellPermissionIdentity( + () -> { + HealthConnectDeviceConfigManager.initializeInstance(mContext); + HealthConnectDeviceConfigManager.getInitialisedInstance() + .updateRateLimiterValues(); + }, + Manifest.permission.READ_DEVICE_CONFIG); } @Test @@ -116,7 +86,7 @@ public class RateLimiterTest { RateLimiter.clearCache(); @QuotaCategory.Type int quotaCategory = 1; tryAcquireCallQuotaNTimes( - quotaCategory, IS_IN_FOREGROUND_TRUE, MAX_FOREGROUND_CALL_15M + 1); + quotaCategory, IS_IN_FOREGROUND_TRUE, MAX_FOREGROUND_READ_CALL_15M + 1); } @Test @@ -132,7 +102,7 @@ public class RateLimiterTest { RateLimiter.clearCache(); @QuotaCategory.Type int quotaCategoryRead = 2; tryAcquireCallQuotaNTimes( - quotaCategoryRead, IS_IN_FOREGROUND_TRUE, MAX_FOREGROUND_CALL_15M); + quotaCategoryRead, IS_IN_FOREGROUND_TRUE, MAX_FOREGROUND_READ_CALL_15M); } @Test @@ -149,10 +119,10 @@ public class RateLimiterTest { @QuotaCategory.Type int quotaCategoryRead = 2; Instant startTime = Instant.now(); tryAcquireCallQuotaNTimes( - quotaCategoryRead, IS_IN_FOREGROUND_TRUE, MAX_FOREGROUND_CALL_15M); + quotaCategoryRead, IS_IN_FOREGROUND_TRUE, MAX_FOREGROUND_READ_CALL_15M); Instant endTime = Instant.now(); int ceilQuotaAcquired = - getCeilQuotaAcquired(startTime, endTime, WINDOW_15M, MAX_FOREGROUND_CALL_15M); + getCeilQuotaAcquired(startTime, endTime, WINDOW_15M, MAX_FOREGROUND_READ_CALL_15M); exception.expect(HealthConnectException.class); exception.expectMessage(containsString("API call quota exceeded")); tryAcquireCallQuotaNTimes(quotaCategoryRead, IS_IN_FOREGROUND_TRUE, ceilQuotaAcquired); diff --git a/tests/unittests/src/com/android/server/healthconnect/TestUtils.java b/tests/unittests/src/com/android/server/healthconnect/TestUtils.java index 5084836d..b9c3ec38 100644 --- a/tests/unittests/src/com/android/server/healthconnect/TestUtils.java +++ b/tests/unittests/src/com/android/server/healthconnect/TestUtils.java @@ -16,8 +16,12 @@ package com.android.server.healthconnect; +import android.app.UiAutomation; import android.os.UserHandle; +import androidx.annotation.NonNull; +import androidx.test.platform.app.InstrumentationRegistry; + import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeoutException; @@ -70,4 +74,19 @@ public final class TestUtils { .getCompletedTaskCount()), 15); } + + /** Runs a {@link Runnable} adopting a subset of Shell's permissions. */ + public static void runWithShellPermissionIdentity( + @NonNull Runnable runnable, String... permissions) { + final UiAutomation uiAutomation = + InstrumentationRegistry.getInstrumentation().getUiAutomation(); + uiAutomation.adoptShellPermissionIdentity(permissions); + try { + runnable.run(); + } catch (Exception e) { + throw new RuntimeException("Caught exception", e); + } finally { + uiAutomation.dropShellPermissionIdentity(); + } + } } diff --git a/tests/unittests/src/com/android/server/healthconnect/permission/FirstGrantTimeUnitTest.java b/tests/unittests/src/com/android/server/healthconnect/permission/FirstGrantTimeUnitTest.java index 12e55140..4af633c2 100644 --- a/tests/unittests/src/com/android/server/healthconnect/permission/FirstGrantTimeUnitTest.java +++ b/tests/unittests/src/com/android/server/healthconnect/permission/FirstGrantTimeUnitTest.java @@ -47,6 +47,7 @@ import com.android.server.healthconnect.TestUtils; import org.junit.After; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.mockito.ArgumentMatchers; import org.mockito.Mock; @@ -115,6 +116,7 @@ public class FirstGrantTimeUnitTest { } @Test + @Ignore("b/312712918 this test is flaky") public void testCurrentPackage_intentSupported_grantTimeIsNotNull() { assertThat(mGrantTimeManager.getFirstGrantTime(SELF_PACKAGE_NAME, CURRENT_USER)) .isNotNull(); @@ -144,6 +146,7 @@ public class FirstGrantTimeUnitTest { } @Test + @Ignore("b/312712918 this test is flaky") public void testCurrentPackage_noBackup_useRecordedTime() { Instant stateTime = Instant.now().minusSeconds((long) 1e5); UserGrantTimeState stagedState = setupGrantTimeState(stateTime, null); @@ -156,6 +159,7 @@ public class FirstGrantTimeUnitTest { } @Test + @Ignore("b/312712918 this test is flaky") public void testCurrentPackage_noBackup_grantTimeEqualToStaged() { Instant backupTime = Instant.now().minusSeconds((long) 1e5); Instant stateTime = backupTime.plusSeconds(10); diff --git a/tests/unittests/src/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelperTest.java b/tests/unittests/src/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelperTest.java index 8d4b270d..58f27795 100644 --- a/tests/unittests/src/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelperTest.java +++ b/tests/unittests/src/com/android/server/healthconnect/storage/datatypehelpers/HealthDataCategoryPriorityHelperTest.java @@ -745,6 +745,52 @@ public class HealthDataCategoryPriorityHelperTest { } @Test + public void testOldReSyncHealthDataPriorityTable_maintainsExistingOrdering() { + when(mHealthConnectDeviceConfigManager.isAggregationSourceControlsEnabled()) + .thenReturn(false); + // Setup current priority list + Map<Integer, List<Long>> priorityList = new HashMap<>(); + priorityList.put( + HealthDataCategory.ACTIVITY, + List.of(APP_PACKAGE_ID_3, APP_PACKAGE_ID, APP_PACKAGE_ID_2)); + setupPriorityList(priorityList); + + // Setup contributor apps + Map<Integer, Set<String>> recordTypesToContributorPackages = new HashMap<>(); + recordTypesToContributorPackages.put( + RecordTypeIdentifier.RECORD_TYPE_STEPS, + Set.of(APP_PACKAGE_NAME, APP_PACKAGE_NAME_2, APP_PACKAGE_NAME_3)); + when(mAppInfoHelper.getRecordTypesToContributingPackagesMap()) + .thenReturn(recordTypesToContributorPackages); + + // Setup apps with write permissions + mPackageInfo1.packageName = APP_PACKAGE_NAME; + mPackageInfo1.requestedPermissions = new String[] {HealthPermissions.WRITE_STEPS}; + mPackageInfo1.requestedPermissionsFlags = + new int[] {PackageInfo.REQUESTED_PERMISSION_GRANTED}; + + mPackageInfo2.packageName = APP_PACKAGE_NAME_2; + mPackageInfo2.requestedPermissions = new String[] {HealthPermissions.WRITE_STEPS}; + mPackageInfo2.requestedPermissionsFlags = + new int[] {PackageInfo.REQUESTED_PERMISSION_GRANTED}; + + mPackageInfo3.packageName = APP_PACKAGE_NAME_3; + mPackageInfo3.requestedPermissions = new String[] {HealthPermissions.WRITE_STEPS}; + mPackageInfo3.requestedPermissionsFlags = + new int[] {PackageInfo.REQUESTED_PERMISSION_GRANTED}; + when(HealthConnectManager.isHealthPermission(any(), any())).thenReturn(true); + when(mPackageInfoUtils.getPackagesHoldingHealthPermissions(any(), any())) + .thenReturn(List.of(mPackageInfo1, mPackageInfo2, mPackageInfo3)); + + mHealthDataCategoryPriorityHelper.reSyncHealthDataPriorityTable(mContext); + + assertThat( + mHealthDataCategoryPriorityHelper.getAppIdPriorityOrder( + HealthDataCategory.ACTIVITY)) + .isEqualTo(List.of(APP_PACKAGE_ID_3, APP_PACKAGE_ID, APP_PACKAGE_ID_2)); + } + + @Test public void testOldReSyncHealthDataPriorityTable_addsNewApps_withWritePermission() { when(mHealthConnectDeviceConfigManager.isAggregationSourceControlsEnabled()) .thenReturn(false); |