Compare commits

...

110 Commits
14.8.1 ... main

Author SHA1 Message Date
renovate[bot] fbfcbdc555
Update dependency androidx.sqlite:sqlite-bundled to v2.6.2 (#4006)
* Update dependency androidx.sqlite:sqlite-bundled to v2.6.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2 weeks ago
renovate[bot] 51e347f22b
Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.5 (#4005)
* Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.5

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2 weeks ago
renovate[bot] d0c28baf7b
Update dependency androidx.compose:compose-bom to v2025.11.01 (#4002)
* Update dependency androidx.compose:compose-bom to v2025.11.01

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2 weeks ago
Pierfrancesco Passerini 3d4d44849e Translated using Weblate (Italian)
Currently translated at 99.6% (654 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/it/
3 weeks ago
renovate[bot] dfa41c515a
Update protobuf monorepo to v4.33.1 (#3998)
* Update protobuf monorepo to v4.33.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 weeks ago
M a539b3a3e4
Remove codebeat badge (#3996) 3 weeks ago
renovate[bot] 8d6de19b2a
Update agp to v8.13.1 (#3997)
* Update agp to v8.13.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 weeks ago
Alex Baker 2a6e1638c9 Update version and changelog 4 weeks ago
Alex Baker 9190930745 Random reminder fixes
- Make random reminder calculation deterministic
- Don't fire reminders immediately on recurring tasks
4 weeks ago
Alex Baker 40961dad87 Refactor custom and random reminder dialogs 4 weeks ago
renovate[bot] 9fbe27345d
Update dependency com.google.android.gms:oss-licenses-plugin to v0.10.9 (#3968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 5f67e0ea3a
Update okhttp monorepo to v5 (major) (#3710)
* Update okhttp monorepo to v5

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] d51171b17e
Update plugin jetbrains-compose to v1.9.3 (#3983)
* Update plugin jetbrains-compose to v1.9.3

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] b657773a2d
Update wearCompose to v1.5.5 (#3973)
* Update wearCompose to v1.5.5

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] d84effc447
Update dependency androidx.compose.material3.adaptive:adaptive-navigation-android to v1.2.0 (#3976)
* Update dependency androidx.compose.material3.adaptive:adaptive-navigation-android to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 21db540614
Update dependency androidx.core:core-splashscreen to v1.2.0 (#3977)
* Update dependency androidx.core:core-splashscreen to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
Alex Baker 8e9f27c46e Fall back to internal storage...
if external private storage is unavailable
4 weeks ago
Alex Baker 94ad2a381e Remove use of internal padding 4 weeks ago
renovate[bot] 747928c8c7
Update dependency io.grpc:grpc-kotlin-stub to v1.5.0 (#3981)
* Update dependency io.grpc:grpc-kotlin-stub to v1.5.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 9a28f1062b
Update dependency com.google.apis:google-api-services-tasks to v1-rev20251102-2.0.0 (#3970)
* Update dependency com.google.apis:google-api-services-tasks to v1-rev20251102-2.0.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] ffe749bf0c
Update dependency androidx.compose.material3.adaptive:adaptive-layout-android to v1.2.0 (#3975)
* Update dependency androidx.compose.material3.adaptive:adaptive-layout-android to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] b0ae0129ae
Update ktor monorepo to v3.3.2 (#3702)
* Update ktor monorepo to v3.3.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 6715369d93
Update dependency androidx.work:work-runtime-ktx to v2.11.0 (#3979)
* Update dependency androidx.work:work-runtime-ktx to v2.11.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 4efb678699
Update dependency androidx.sqlite:sqlite-bundled to v2.6.1 (#3978)
* Update dependency androidx.sqlite:sqlite-bundled to v2.6.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 581b789a0b
Update lifecycle to v2.9.4 (#3972)
* Update lifecycle to v2.9.4

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 15d8b3aa59
Update dependency com.google.gms:google-services to v4.4.4 (#3971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] dcd5d8c094
Update mockito monorepo to v5.20.0 (#3982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] f3253e6188
Update protobuf monorepo to v4.33.0 (#3984)
* Update protobuf monorepo to v4.33.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 34b0c62ef8
Update dependency androidx.navigation:navigation-compose to v2.9.6 (#3967)
* Update dependency androidx.navigation:navigation-compose to v2.9.6

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 3754196714
Update room to v2.8.3 (#3985)
* Update room to v2.8.3

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
Igor Sorocean b94a91efbe Translated using Weblate (Romanian)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/ro/
4 weeks ago
renovate[bot] 2c6066c378
Update dependency androidx.compose:compose-bom to v2025.11.00 (#3974)
* Update dependency androidx.compose:compose-bom to v2025.11.00

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 03e15a8c35
Update GitHub Artifact Actions (major) (#3987)
Update GitHub Artifact Actions

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] e63add73bc
Update dagger.hilt to v2.57.2 (#3965)
* Update dagger.hilt to v2.57.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] bf676bcea7
Update dependency ruby to v3.4.7 (#3896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 6de8fe2fa0
Update dependency com.google.apis:google-api-services-drive to v3-rev20251019-2.0.0 (#3969)
* Update dependency com.google.apis:google-api-services-drive to v3-rev20251019-2.0.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 5907f27172
Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.4 (#3966)
* Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.4

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
Alex Baker 38119d4560 Update version and changelog 4 weeks ago
Alex Baker 52848a5308 Attempt to fix flashing widgets 4 weeks ago
Alex Baker 4c492120b3 Update Google Play Billing to 7.1.1 4 weeks ago
Alex Baker 20e995b19b Fix locale support for Hebrew and Indonesian 4 weeks ago
Alex Baker 2b63e33de2 Fix reentrant deadlock 4 weeks ago
hasak 5980bd497d Translated using Weblate (Bosnian)
Currently translated at 17.0% (112 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/bs/
2 months ago
Erigmac X e16b5cd6cd Translated using Weblate (Indonesian)
Currently translated at 84.8% (28 of 33 strings)

Translation: Tasks.org/Desktop
Translate-URL: https://hosted.weblate.org/projects/tasks/multiplatform/id/
2 months ago
Akihiko Suzuki (array) 3d9945c798 Translated using Weblate (Japanese)
Currently translated at 87.8% (29 of 33 strings)

Translation: Tasks.org/Desktop
Translate-URL: https://hosted.weblate.org/projects/tasks/multiplatform/ja/
2 months ago
Petri Hämäläinen 5c9eb1c35f Translated using Weblate (Finnish)
Currently translated at 100.0% (33 of 33 strings)

Translation: Tasks.org/Desktop
Translate-URL: https://hosted.weblate.org/projects/tasks/multiplatform/fi/
2 months ago
Petri Hämäläinen 6b594a3213 Translated using Weblate (Finnish)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/fi/
2 months ago
Alex Baker 9e0e01f89b Custom filter creation improvements 2 months ago
Alex Baker 932b8b0540 Update version and changelog 3 months ago
Alex Baker 152a9684e5 Downgrade Room and BundledSQLite
https://issuetracker.google.com/issues/442032108
3 months ago
renovate[bot] 88c817b770
Update room to v2.8.0 (#3895)
* Update room to v2.8.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] a368960073
Update hilt to v1.3.0 (#3891)
* Update hilt to v1.3.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
Alex Baker 4f1cc5ab8e Update version and changelog 3 months ago
Alex Baker 17f54b6d32 Disable updatePeriodMillis, limit to 50 items 3 months ago
Alex Baker 6a44bed0e8 Update version and changelog 3 months ago
Alex Baker 7fa90396b3 Limit widgets to 175 rows
200 rows gave me binder size warnings
3 months ago
Alex Baker d1df39d12c Remove limit on widget items 3 months ago
Alex Baker d9ddd45f13 Add WidgetIconProvider 3 months ago
renovate[bot] 27b21118eb
Update dependency com.google.android.gms:play-services-oss-licenses to v17.3.0 (#3889)
* Update dependency com.google.android.gms:play-services-oss-licenses to v17.3.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] f725365f87
Update dependency androidx.sqlite:sqlite-bundled to v2.6.0 (#3888)
* Update dependency androidx.sqlite:sqlite-bundled to v2.6.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
Alex Baker 7377e4672d Fix tests 3 months ago
renovate[bot] 8c90b1ec87
Update dependency androidx.compose:compose-bom to v2025.09.00 (#3887)
* Update dependency androidx.compose:compose-bom to v2025.09.00

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
Alex Baker 5b50f45a5b Fix nextcloud sharing by username 3 months ago
Alex Baker 882338f554 Fix microsoft sync issues
- fix sync on empty categories
- apply parent completion time to checklist items
3 months ago
renovate[bot] 930e980550
Update dependency androidx.activity:activity-compose to v1.11.0 (#3886)
* Update dependency androidx.activity:activity-compose to v1.11.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] fb243c7aaf
Update protobuf monorepo to v4.32.1 (#3884)
* Update protobuf monorepo to v4.32.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] c3842fd2f7
Update wearCompose to v1.5.1 (#3885)
* Update wearCompose to v1.5.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 81ecb322e9
Update dependency androidx.wear:wear-input to v1.2.0 (#3882)
* Update dependency androidx.wear:wear-input to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 1b386458b8
Update dependency androidx.work:work-runtime-ktx to v2.10.4 (#3883)
* Update dependency androidx.work:work-runtime-ktx to v2.10.4

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 7dca092831
Update dependency androidx.navigation:navigation-compose to v2.9.4 (#3881)
* Update dependency androidx.navigation:navigation-compose to v2.9.4

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 2749b029c5
Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.1 (#3880) 3 months ago
Alex Baker 0cb19221c4 Update version and changelog 3 months ago
Alex Baker d900f72a5c Fix Microsoft To Do patching
Encode null in patch so you can remove values
3 months ago
Alex Baker 0d8979b72c Fix moving data to a Google Tasks account
When moving data to a Google Tasks account from any other synchronized
account, the app could fail to delete the data from the source account
3 months ago
Alex Baker 703322f510 Force refresh on resume
Previously visible unstarted or not-due tasks that are now started or
overdue need to be updated
3 months ago
Alex Baker 192351a4b8 Add ScreenUnlockReceiver 3 months ago
Alex Baker 844a3a0ff8 Update version and changelog 3 months ago
Alex Baker e6bbc8d361 Attempt to address Android 16 widget changes 3 months ago
Alex Baker 04ab41f622 Use task ids for widget click intents 3 months ago
Alex Baker 4946b0ca06 Apply missing obj fix to etebase, caldav deletion 3 months ago
Alex Baker 2c29194ff2 Try to recover from missing caldav obj 3 months ago
renovate[bot] 1aaaad86da
Update dependency com.google.apis:google-api-services-drive to v3-rev20250829-2.0.0 (#3860)
* Update dependency com.google.apis:google-api-services-drive to v3-rev20250829-2.0.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
Alex Baker 68601873fd Add RefreshBroadcaster interface 3 months ago
Alex Baker c9721790ce Remove broadcastRefreshList 3 months ago
renovate[bot] 6f16a29fd7
Update dependency com.google.auth:google-auth-library-oauth2-http to v1.39.0 (#3863)
* Update dependency com.google.auth:google-auth-library-oauth2-http to v1.39.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
happy happy a501b81bfc Added translation using Weblate (Hindi) 3 months ago
happy happy fded7fbdd5 Added translation using Weblate (Hindi) 3 months ago
Alex Baker 3ff4a2339b Move KeyStoreEncryption to kmp 3 months ago
vale-decem e400594e5b Translated using Weblate (Serbian)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/sr/
3 months ago
Alex Baker 8bbbc1dcac Update version and changelog 3 months ago
github-actions[bot] 38d27b262a Update dependency diffs 3 months ago
Alex Baker b61842646b Update ical4android
Fixes alarm synchronization
3 months ago
Alex Baker 29cbb33a42 Move VtodoCache to kmp 3 months ago
renovate[bot] 2418b664e9
Update agp to v8.13.0 (#3850)
* Update agp to v8.13.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
vale-decem 0e3803c28d Translated using Weblate (Serbian)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/sr/
3 months ago
glemco c0bb7b306a Translated using Weblate (Italian)
Currently translated at 99.6% (654 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/it/
3 months ago
Alex Baker 903412fdea Update version and changelog 3 months ago
Alex Baker 12b1127e6b Fix clear completed crash when grouping by list 3 months ago
Alex Baker c3ce7a43fb Fix all day calendar events 3 months ago
bittin1ddc447d824349b2 a391dff9bc Translated using Weblate (Swedish)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/sv/
3 months ago
renovate[bot] 28e12110fa
Update lifecycle to v2.9.3 (#3839)
* Update lifecycle to v2.9.3

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 0bba2c4a63
Update wearCompose to v1.5.0 (#3840)
* Update wearCompose to v1.5.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 700421c5ce
Update dependency com.google.android.gms:oss-licenses-plugin to v0.10.8 (#3838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 months ago
renovate[bot] d4a742b136
Update agp to v8.12.2 (#3837)
* Update agp to v8.12.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] 585967c601
Update dependency androidx.wear:wear-input to v1.2.0-rc01 (#3836)
* Update dependency androidx.wear:wear-input to v1.2.0-rc01

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] d279f7c42e
Update dependency androidx.compose:compose-bom to v2025.08.01 (#3835)
* Update dependency androidx.compose:compose-bom to v2025.08.01

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
Milo Ivir 5cfbe9c8cb Translated using Weblate (Croatian)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/hr/
3 months ago
Frits van Bommel f591b1846c Translated using Weblate (Dutch)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/nl/
3 months ago
MisterTechnik 3401a59716 Translated using Weblate (German)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/de/
3 months ago
renovate[bot] d0328b378a
Update dependency com.google.apis:google-api-services-drive to v3-rev20250819-2.0.0 (#3823)
* Update dependency com.google.apis:google-api-services-drive to v3-rev20250819-2.0.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago

@ -29,7 +29,7 @@ jobs:
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '21'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
@ -44,7 +44,7 @@ jobs:
GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }} GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }}
run: bundle exec fastlane bundle run: bundle exec fastlane bundle
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: release name: release
path: | path: |

@ -16,11 +16,11 @@ jobs:
with: with:
bundler-cache: true bundler-cache: true
- name: Set up JDK 17 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '21'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
@ -29,7 +29,7 @@ jobs:
run: bundle exec fastlane lint run: bundle exec fastlane lint
- name: Archive lint reports - name: Archive lint reports
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: ${{ always() }} if: ${{ always() }}
with: with:
name: lint-reports name: lint-reports
@ -45,11 +45,11 @@ jobs:
- name: checkout - name: checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up JDK 17 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '21'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
@ -89,7 +89,7 @@ jobs:
script: ./gradlew -Pcoverage app:test${{ matrix.flavor }}DebugUnitTest app:connected${{ matrix.flavor }}DebugAndroidTest script: ./gradlew -Pcoverage app:test${{ matrix.flavor }}DebugUnitTest app:connected${{ matrix.flavor }}DebugAndroidTest
- name: Upload test reports - name: Upload test reports
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
if: ${{ always() }} if: ${{ always() }}
with: with:
name: test-reports-${{ matrix.flavor }} name: test-reports-${{ matrix.flavor }}

@ -30,7 +30,7 @@ jobs:
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '21'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4

@ -24,7 +24,7 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- uses: actions/download-artifact@v5 - uses: actions/download-artifact@v6
with: with:
name: release name: release
path: . path: .

@ -1 +1 @@
3.4.5 3.4.7

@ -1,3 +1,39 @@
### 14.8.4 (2025-11-09)
* Fix flashing widgets [#3902](https://github.com/tasks/tasks/issues/3902)
* Fix random reminder scheduling
* Fix random reminders firing immediately on recurring tasks [#3904](https://github.com/tasks/tasks/issues/3904)
* Fix deadlock when adding new task
* Fix crash in settings when backup location unavailable [#3989](https://github.com/tasks/tasks/issues/3989)
* Fix Hebrew and Indonesian support [#3928](https://github.com/tasks/tasks/issues/3928)
* Update translations
* Bosnian - @hasak
* Finnish - @pHamala
* Indonesian - @erigmac
* Japanese - @array
* Romanian - @ygorigor
### 14.8.3 (2025-09-16)
* Fix crash on Android 10 and below
### 14.8.2 (2025-09-14)
* Fix blank widgets on Android 16 QPR1 [#3847](https://github.com/tasks/tasks/issues/3847)
* Fix all-day calendar events [#1534](https://github.com/tasks/tasks/issues/1534)
* Fix alarm synchronization [#3859](https://github.com/tasks/tasks/issues/3859)
* Fix sync failure when migrating data from EteSync to CalDAV [#3869](https://github.com/tasks/tasks/issues/3869)
* Fix removing values from Microsoft To Do [#3862](https://github.com/tasks/tasks/issues/3862)
* Fix share invites for Nextcloud [#2386](https://github.com/tasks/tasks/issues/2386)
* Fix failure to delete source data when moving to Google Tasks [#3867](https://github.com/tasks/tasks/issues/3867)
* Fix crash when clearing completed while grouping by lists
* Update translations
* Croatian - @milotype
* Dutch - @fvbommel
* German - @MisterTechnik
* Italian - @glemco
* Serbian - @vale-decem
### 14.8.1 (2025-08-24) ### 14.8.1 (2025-08-24)
* System bar scrim improvements * System bar scrim improvements

@ -15,7 +15,7 @@ Please visit [tasks.org](https://tasks.org) for end user documentation and suppo
[![PayPal donate button](https://img.shields.io/badge/paypal-donate-yellow.svg?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alex@tasks.org) [![PayPal donate button](https://img.shields.io/badge/paypal-donate-yellow.svg?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alex@tasks.org)
[![Liberapay donate button](https://img.shields.io/liberapay/receives/tasks.svg?logo=liberapay)](https://liberapay.com/tasks/donate) [![Liberapay donate button](https://img.shields.io/liberapay/receives/tasks.svg?logo=liberapay)](https://liberapay.com/tasks/donate)
[![build](https://github.com/tasks/tasks/actions/workflows/bundle.yml/badge.svg)](https://github.com/tasks/tasks/actions/workflows/bundle.yml) [![weblate](https://hosted.weblate.org/widgets/tasks/-/android/svg-badge.svg)](https://hosted.weblate.org/engage/tasks/?utm_source=widget) [![codebeat badge](https://codebeat.co/badges/07924fca-2f18-4eff-99a3-120ec5ac2d5f)](https://codebeat.co/projects/github-com-tasks-tasks-main) [![build](https://github.com/tasks/tasks/actions/workflows/bundle.yml/badge.svg)](https://github.com/tasks/tasks/actions/workflows/bundle.yml) [![weblate](https://hosted.weblate.org/widgets/tasks/-/android/svg-badge.svg)](https://hosted.weblate.org/engage/tasks/?utm_source=widget)
### Contributing ### Contributing

@ -349,7 +349,7 @@ class DateUtilitiesTest {
} }
@Test @Test
fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("iw")) { fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("he")) {
freezeAt(DateTime(2018, 12, 12)) { freezeAt(DateTime(2018, 12, 12)) {
assertMatches( assertMatches(
"יום ראשון, 14 בינואר( בשעה)? 13:45", "יום ראשון, 14 בינואר( בשעה)? 13:45",
@ -359,7 +359,7 @@ class DateUtilitiesTest {
} }
@Test @Test
fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("iw")) { fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("he")) {
freezeAt(DateTime(2017, 12, 12)) { freezeAt(DateTime(2017, 12, 12)) {
assertMatches( assertMatches(
"יום ראשון, 14 בינואר 2018( בשעה)? 13:45", "יום ראשון, 14 בינואר 2018( בשעה)? 13:45",

@ -56,7 +56,7 @@ class TaskMoverTest : InjectingTestCase() {
createTasks(1) createTasks(1)
googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1"))) googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1")))
moveToGoogleTasks("2", 1) moveToGoogleTasks("2", 1)
val deleted = googleTaskDao.getDeletedByTaskId(1) val deleted = googleTaskDao.getDeletedByTaskId(1, "account1")
assertEquals(1, deleted.size.toLong()) assertEquals(1, deleted.size.toLong())
assertEquals(1, deleted[0].task) assertEquals(1, deleted[0].task)
assertTrue(deleted[0].deleted > 0) assertTrue(deleted[0].deleted > 0)
@ -71,7 +71,7 @@ class TaskMoverTest : InjectingTestCase() {
googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1"))) googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1")))
googleTaskDao.insert(newCaldavTask(with(TASK, 2), with(CALENDAR, "1"))) googleTaskDao.insert(newCaldavTask(with(TASK, 2), with(CALENDAR, "1")))
moveToGoogleTasks("2", 1) moveToGoogleTasks("2", 1)
val deleted = googleTaskDao.getDeletedByTaskId(2) val deleted = googleTaskDao.getDeletedByTaskId(2, "account1")
assertEquals(1, deleted.size.toLong()) assertEquals(1, deleted.size.toLong())
assertEquals(2, deleted[0].task) assertEquals(2, deleted[0].task)
assertTrue(deleted[0].deleted > 0) assertTrue(deleted[0].deleted > 0)
@ -249,7 +249,7 @@ class TaskMoverTest : InjectingTestCase() {
createTasks(1) createTasks(1)
googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1"))) googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1")))
moveToGoogleTasks("1", 1) moveToGoogleTasks("1", 1)
assertTrue(googleTaskDao.getDeletedByTaskId(1).isEmpty()) assertTrue(googleTaskDao.getDeletedByTaskId(1, "account1").isEmpty())
assertEquals(1, googleTaskDao.getAllByTaskId(1).size.toLong()) assertEquals(1, googleTaskDao.getAllByTaskId(1).size.toLong())
} }

@ -1,13 +1,13 @@
package org.tasks.caldav package org.tasks.caldav
import org.tasks.data.UUIDHelper
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.tasks.data.UUIDHelper
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OPEN_XCHANGE import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OPEN_XCHANGE
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_TASKS import org.tasks.data.entity.CaldavAccount.Companion.SERVER_TASKS
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_UNKNOWN import org.tasks.data.entity.CaldavAccount.Companion.SERVER_UNKNOWN
@ -36,7 +36,7 @@ class ServerDetectionTest : CaldavTest() {
sync() sync()
assertEquals(SERVER_OWNCLOUD, loadAccount().serverType) assertEquals(SERVER_NEXTCLOUD, loadAccount().serverType)
} }
@Test @Test

@ -0,0 +1,25 @@
package org.tasks.data
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertTrue
import org.junit.Test
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.injection.InjectingTestCase
import javax.inject.Inject
@HiltAndroidTest
class CaldavDaoExtensionsTest : InjectingTestCase() {
@Inject lateinit var caldavDao: CaldavDao
@Test
fun getLocalListCreatesAccountIfNeeded() = runBlocking {
withTimeout(5000L) {
assertTrue(caldavDao.getAccounts().isEmpty())
caldavDao.getLocalList()
assertTrue(caldavDao.getAccounts(CaldavAccount.TYPE_LOCAL).isNotEmpty())
}
}
}

@ -4,27 +4,24 @@ import android.app.Activity
import android.content.Context import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.SkuType import com.android.billingclient.api.BillingClient.ProductType
import com.android.billingclient.api.BillingClient.newBuilder import com.android.billingclient.api.BillingClient.newBuilder
import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProrationMode import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.Purchase.PurchaseState import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.SkuDetailsParams import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.consumePurchase import com.android.billingclient.api.consumePurchase
import com.android.billingclient.api.queryPurchasesAsync
import com.android.billingclient.api.querySkuDetails
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager
@ -49,32 +46,61 @@ class BillingClientImpl(
override suspend fun getSkus(skus: List<String>): List<Sku> = override suspend fun getSkus(skus: List<String>): List<Sku> =
executeServiceRequest { executeServiceRequest {
val skuDetailsResult = withContext(Dispatchers.IO) { val productList = skus.map {
billingClient.querySkuDetails( QueryProductDetailsParams.Product.newBuilder()
SkuDetailsParams .setProductId(it)
.newBuilder() .setProductType(ProductType.SUBS)
.setType(SkuType.SUBS)
.setSkusList(skus)
.build() .build()
)
} }
skuDetailsResult.billingResult.let { val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
val productDetailsResult = withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
cont.resume(billingResult to productDetailsList)
}
}
}
productDetailsResult.first.let {
if (!it.success) { if (!it.success) {
throw IllegalStateException(it.responseCodeString) throw IllegalStateException(it.responseCodeString)
} }
} }
val json = Json { ignoreUnknownKeys = true }
skuDetailsResult productDetailsResult.second?.map { productDetails ->
.skuDetailsList Sku(
?.map { json.decodeFromString<Sku>(it.originalJson) } productId = productDetails.productId,
?: emptyList() price = productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
?: productDetails.oneTimePurchaseOfferDetails?.formattedPrice
?: ""
)
} ?: emptyList()
} }
override suspend fun queryPurchases(throwError: Boolean) = try { override suspend fun queryPurchases(throwError: Boolean) = try {
executeServiceRequest { executeServiceRequest {
withContext(Dispatchers.IO + NonCancellable) { withContext(Dispatchers.IO + NonCancellable) {
val subs = billingClient.queryPurchasesAsync(SkuType.SUBS) val subsParams = QueryPurchasesParams.newBuilder()
val iaps = billingClient.queryPurchasesAsync(SkuType.INAPP) .setProductType(ProductType.SUBS)
.build()
val iapsParams = QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build()
val subs = suspendCoroutine { cont ->
billingClient.queryPurchasesAsync(subsParams) { billingResult, purchases ->
cont.resume(PurchasesResult(billingResult, purchases))
}
}
val iaps = suspendCoroutine { cont ->
billingClient.queryPurchasesAsync(iapsParams) { billingResult, purchases ->
cont.resume(PurchasesResult(billingResult, purchases))
}
}
if (subs.success || iaps.success) { if (subs.success || iaps.success) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
inventory.clear() inventory.clear()
@ -105,7 +131,7 @@ class BillingClientImpl(
purchases?.forEach { purchases?.forEach {
firebase.reportIabResult( firebase.reportIabResult(
result.responseCodeString, result.responseCodeString,
it.skus.joinToString(","), it.products.joinToString(","),
it.purchaseState.purchaseStateString it.purchaseState.purchaseStateString
) )
} }
@ -122,31 +148,57 @@ class BillingClientImpl(
oldPurchase: Purchase? oldPurchase: Purchase?
) { ) {
executeServiceRequest { executeServiceRequest {
val skuDetailsResult = withContext(Dispatchers.IO) { val productList = listOf(
billingClient.querySkuDetails( QueryProductDetailsParams.Product.newBuilder()
SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType) .setProductId(sku)
.setProductType(skuType)
.build() .build()
) )
val queryParams = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
val productDetailsResult = withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.queryProductDetailsAsync(queryParams) { billingResult, productDetailsList ->
cont.resume(billingResult to productDetailsList)
}
}
} }
skuDetailsResult.billingResult.let {
productDetailsResult.first.let {
if (!it.success) { if (!it.success) {
throw IllegalStateException(it.responseCodeString) throw IllegalStateException(it.responseCodeString)
} }
} }
val skuDetails =
skuDetailsResult val productDetails = productDetailsResult.second?.firstOrNull()
.skuDetailsList ?: throw IllegalStateException("Product $sku not found")
?.firstOrNull()
?: throw IllegalStateException("Sku $sku not found") val productDetailsParamsBuilder = ProductDetailsParams.newBuilder()
val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails) .setProductDetails(productDetails)
// For subscriptions (including legacy subscriptions), we need to provide an offer token
if (skuType == ProductType.SUBS) {
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
?: throw IllegalStateException("No offer token found for subscription $sku")
productDetailsParamsBuilder.setOfferToken(offerToken)
}
val productDetailsParams = productDetailsParamsBuilder.build()
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
oldPurchase?.let { oldPurchase?.let {
params.setSubscriptionUpdateParams( params.setSubscriptionUpdateParams(
SubscriptionUpdateParams.newBuilder() SubscriptionUpdateParams.newBuilder()
.setOldSkuPurchaseToken(it.purchaseToken) .setOldPurchaseToken(it.purchaseToken)
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION) .setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION)
.build() .build()
) )
} }
if (activity is OnPurchasesUpdated) { if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity onPurchasesUpdated = activity
} }
@ -214,17 +266,28 @@ class BillingClientImpl(
ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(), ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(),
) )
Timber.d("consume purchase: ${result.billingResult.responseCodeString}") Timber.d("consume purchase: ${result.billingResult.responseCodeString}")
queryPurchases() queryPurchases(throwError = false)
} }
} }
private data class PurchasesResult(
val billingResult: BillingResult,
val purchasesList: List<com.android.billingclient.api.Purchase>
) {
val success: Boolean
get() = billingResult.responseCode == BillingResponseCode.OK
val responseCodeString: String
get() = billingResult.responseCodeString
val purchases: List<com.android.billingclient.api.Purchase>
get() = purchasesList
}
companion object { companion object {
const val TYPE_SUBS = SkuType.SUBS const val TYPE_SUBS = ProductType.SUBS
const val STATE_PURCHASED = PurchaseState.PURCHASED const val STATE_PURCHASED = PurchaseState.PURCHASED
private val PurchasesResult.success: Boolean
get() = billingResult.responseCode == BillingResponseCode.OK
private val BillingResult.success: Boolean private val BillingResult.success: Boolean
get() = responseCode == BillingResponseCode.OK get() = responseCode == BillingResponseCode.OK
@ -251,11 +314,5 @@ class BillingClientImpl(
PurchaseState.PENDING -> "PENDING" PurchaseState.PENDING -> "PENDING"
else -> this.toString() else -> this.toString()
} }
private val PurchasesResult.responseCodeString: String
get() = billingResult.responseCodeString
private val PurchasesResult.purchases: List<com.android.billingclient.api.Purchase>
get() = purchasesList
} }
} }

@ -31,7 +31,7 @@ class Purchase(private val purchase: Purchase) {
get() = purchase.signature get() = purchase.signature
val sku: String val sku: String
get() = purchase.skus.first() get() = purchase.products.first()
val purchaseToken: String val purchaseToken: String
get() = purchase.purchaseToken get() = purchase.purchaseToken
@ -55,7 +55,7 @@ class Purchase(private val purchase: Purchase) {
get() { get() {
val matcher = PATTERN.matcher(sku) val matcher = PATTERN.matcher(sku)
if (matcher.matches()) { if (matcher.matches()) {
val price = matcher.group(2).toInt() val price = matcher.group(2)?.toInt()
return if (price == 499) 5 else price return if (price == 499) 5 else price
} }
return null return null

@ -387,6 +387,13 @@
android:resource="@xml/file_provider_paths"/> android:resource="@xml/file_provider_paths"/>
</provider> </provider>
<provider
android:name=".widget.WidgetIconProvider"
android:authorities="${applicationId}.widgeticons"
android:exported="true"
android:grantUriPermissions="true"
tools:ignore="ExportedContentProvider" />
<receiver <receiver
android:name="org.dmfs.provider.tasks.TaskProviderBroadcastReceiver" android:name="org.dmfs.provider.tasks.TaskProviderBroadcastReceiver"
tools:node="remove"/> tools:node="remove"/>

@ -91,8 +91,7 @@ class MainActivityViewModel @Inject constructor(
private val refreshReceiver = object : BroadcastReceiver() { private val refreshReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
LocalBroadcastManager.REFRESH, LocalBroadcastManager.REFRESH -> _updateFilters.update { currentTimeMillis() }
LocalBroadcastManager.REFRESH_LIST -> _updateFilters.update { currentTimeMillis() }
} }
} }
} }
@ -216,12 +215,12 @@ class MainActivityViewModel @Inject constructor(
when (subheader.subheaderType) { when (subheader.subheaderType) {
NavigationDrawerSubheader.SubheaderType.PREFERENCE -> { NavigationDrawerSubheader.SubheaderType.PREFERENCE -> {
tasksPreferences.set(booleanPreferencesKey(subheader.id), collapsed) tasksPreferences.set(booleanPreferencesKey(subheader.id), collapsed)
localBroadcastManager.broadcastRefreshList() localBroadcastManager.broadcastRefresh()
} }
NavigationDrawerSubheader.SubheaderType.CALDAV, NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS -> { NavigationDrawerSubheader.SubheaderType.TASKS -> {
caldavDao.setCollapsed(subheader.id, collapsed) caldavDao.setCollapsed(subheader.id, collapsed)
localBroadcastManager.broadcastRefreshList() localBroadcastManager.broadcastRefresh()
} }
} }
} }

@ -782,10 +782,12 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
colorProvider.getPriorityColor(3)) colorProvider.getPriorityColor(3))
} }
@SuppressLint("NotifyDataSetChanged")
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
listViewModel.invalidate() listViewModel.invalidate()
localBroadcastManager.registerTaskCompletedReceiver(repeatConfirmationReceiver) localBroadcastManager.registerTaskCompletedReceiver(repeatConfirmationReceiver)
recyclerAdapter?.notifyDataSetChanged() // force rebind to update timestamps (hidden/overdue)
} }
private fun makeSnackbar(@StringRes res: Int, vararg args: Any?): Snackbar? { private fun makeSnackbar(@StringRes res: Int, vararg args: Any?): Snackbar? {

@ -3,8 +3,8 @@ package com.todoroo.astrid.adapter
import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskMover import com.todoroo.astrid.service.TaskMover
import com.todoroo.astrid.subtasks.SubtasksFilterUpdater import com.todoroo.astrid.subtasks.SubtasksFilterUpdater
import org.tasks.LocalBroadcastManager
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.TaskContainer import org.tasks.data.TaskContainer
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao import org.tasks.data.dao.GoogleTaskDao
@ -24,9 +24,9 @@ class AstridTaskAdapter internal constructor(
googleTaskDao: GoogleTaskDao, googleTaskDao: GoogleTaskDao,
caldavDao: CaldavDao, caldavDao: CaldavDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
taskMover: TaskMover, taskMover: TaskMover,
) : TaskAdapter(false, googleTaskDao, caldavDao, taskDao, localBroadcastManager, taskMover) { ) : TaskAdapter(false, googleTaskDao, caldavDao, taskDao, refreshBroadcaster, taskMover) {
private val chainedCompletions = Collections.synchronizedMap(HashMap<String, ArrayList<String>>()) private val chainedCompletions = Collections.synchronizedMap(HashMap<String, ArrayList<String>>())
@ -56,7 +56,7 @@ class AstridTaskAdapter internal constructor(
for (i in 0 until abs(delta)) { for (i in 0 until abs(delta)) {
updater.indent(list, filter, targetTaskId, delta) updater.indent(list, filter, targetTaskId, delta)
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} }

@ -6,7 +6,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.activities.TagSettingsActivity import org.tasks.activities.TagSettingsActivity
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
@ -30,7 +30,7 @@ class SubheaderClickHandler @Inject constructor(
private val activity: Activity, private val activity: Activity,
private val tasksPreferences: TasksPreferences, private val tasksPreferences: TasksPreferences,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
): SubheaderViewHolder.ClickHandler { ): SubheaderViewHolder.ClickHandler {
override fun onClick(subheader: NavigationDrawerSubheader) { override fun onClick(subheader: NavigationDrawerSubheader) {
(activity as AppCompatActivity).lifecycleScope.launch { (activity as AppCompatActivity).lifecycleScope.launch {
@ -40,7 +40,7 @@ class SubheaderClickHandler @Inject constructor(
CALDAV, CALDAV,
TASKS -> caldavDao.setCollapsed(subheader.id, collapsed) TASKS -> caldavDao.setCollapsed(subheader.id, collapsed)
} }
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
} }

@ -13,7 +13,7 @@ import com.todoroo.astrid.core.SortHelper.SORT_START
import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskMover import com.todoroo.astrid.service.TaskMover
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.TaskContainer import org.tasks.data.TaskContainer
import org.tasks.data.createDueDate import org.tasks.data.createDueDate
import org.tasks.data.createHideUntil import org.tasks.data.createHideUntil
@ -31,7 +31,7 @@ open class TaskAdapter(
private val googleTaskDao: GoogleTaskDao, private val googleTaskDao: GoogleTaskDao,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val taskMover: TaskMover, private val taskMover: TaskMover,
) { ) {
private val selected = HashSet<Long>() private val selected = HashSet<Long>()
@ -296,7 +296,7 @@ open class TaskAdapter(
taskDao.setOrder(task.id, task.task.order) taskDao.setOrder(task.id, task.task.order)
taskDao.setParent(newParentId, listOf(task.id)) taskDao.setParent(newParentId, listOf(task.id))
taskDao.touch(task.id) taskDao.touch(task.id)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
protected suspend fun moveGoogleTask(from: Int, to: Int, indent: Int) { protected suspend fun moveGoogleTask(from: Int, to: Int, indent: Int) {
@ -375,7 +375,7 @@ open class TaskAdapter(
} }
} }
taskDao.touch(task.id) taskDao.touch(task.id)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
googleTaskDao.validateSorting(task.caldav!!) googleTaskDao.validateSorting(task.caldav!!)
} }
@ -407,7 +407,7 @@ open class TaskAdapter(
newPosition = newPosition, newPosition = newPosition,
) )
taskDao.touch(task.id) taskDao.touch(task.id)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun changeCaldavParent(task: TaskContainer, indent: Int, to: Int): Long { private suspend fun changeCaldavParent(task: TaskContainer, indent: Int, to: Int): Long {

@ -75,10 +75,16 @@ class AlarmCalculator(
*/ */
private fun calculateNextRandomReminder(random: Random, task: Task, reminderPeriod: Long) = private fun calculateNextRandomReminder(random: Random, task: Task, reminderPeriod: Long) =
if (reminderPeriod > 0) { if (reminderPeriod > 0) {
val baseline = when {
task.reminderLast > 0 -> task.reminderLast
task.isRecurring -> task.modificationDate
else -> task.creationDate
}
val multiplier = 0.85f + 0.3f * random.nextFloat(task.id + baseline)
maxOf( maxOf(
task.reminderLast baseline.plus((reminderPeriod * multiplier).toLong()),
.coerceAtLeast(task.creationDate)
.plus((reminderPeriod * (0.85f + 0.3f * random.nextFloat())).toLong()),
task.hideUntil task.hideUntil
) )
} else { } else {

@ -5,7 +5,7 @@
*/ */
package com.todoroo.astrid.alarms package com.todoroo.astrid.alarms
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.dao.AlarmDao import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.TaskDao import org.tasks.data.dao.TaskDao
import org.tasks.data.db.DbUtils import org.tasks.data.db.DbUtils
@ -28,7 +28,7 @@ import javax.inject.Inject
class AlarmService @Inject constructor( class AlarmService @Inject constructor(
private val alarmDao: AlarmDao, private val alarmDao: AlarmDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val notificationManager: NotificationManager, private val notificationManager: NotificationManager,
private val workManager: WorkManager, private val workManager: WorkManager,
private val alarmCalculator: AlarmCalculator, private val alarmCalculator: AlarmCalculator,
@ -54,7 +54,7 @@ class AlarmService @Inject constructor(
changed = true changed = true
} }
if (changed) { if (changed) {
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
return changed return changed
} }

@ -6,7 +6,7 @@
package com.todoroo.astrid.dao package com.todoroo.astrid.dao
import com.todoroo.astrid.timers.TimerPlugin import com.todoroo.astrid.timers.TimerPlugin
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.TaskContainer import org.tasks.data.TaskContainer
import org.tasks.data.count import org.tasks.data.count
import org.tasks.data.dao.TaskDao import org.tasks.data.dao.TaskDao
@ -28,7 +28,7 @@ import javax.inject.Inject
class TaskDao @Inject constructor( class TaskDao @Inject constructor(
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val notificationManager: NotificationManager, private val notificationManager: NotificationManager,
private val geofenceApi: GeofenceApi, private val geofenceApi: GeofenceApi,
private val timerPlugin: TimerPlugin, private val timerPlugin: TimerPlugin,
@ -82,7 +82,7 @@ class TaskDao @Inject constructor(
suspend fun setCollapsed(id: Long, collapsed: Boolean) { suspend fun setCollapsed(id: Long, collapsed: Boolean) {
taskDao.setCollapsed(listOf(id), collapsed) taskDao.setCollapsed(listOf(id), collapsed)
syncAdapters.sync() syncAdapters.sync()
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
suspend fun setCollapsed(preferences: Preferences, filter: Filter, collapsed: Boolean) { suspend fun setCollapsed(preferences: Preferences, filter: Filter, collapsed: Boolean) {
@ -103,7 +103,7 @@ class TaskDao @Inject constructor(
Timber.d("Saved $task") Timber.d("Saved $task")
afterUpdate(task, original) afterUpdate(task, original)
if (!task.isSuppressRefresh()) { if (!task.isSuppressRefresh()) {
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
workManager.triggerNotifications() workManager.triggerNotifications()
workManager.scheduleRefresh() workManager.scheduleRefresh()

@ -11,6 +11,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.CalendarContract import android.provider.CalendarContract
import android.text.format.Time import android.text.format.Time
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
@ -19,7 +20,7 @@ import org.tasks.data.dao.TaskDao
import org.tasks.data.entity.Task import org.tasks.data.entity.Task
import org.tasks.preferences.PermissionChecker import org.tasks.preferences.PermissionChecker
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.time.DateTimeUtils2.currentTimeMillis import org.tasks.time.DateTime
import org.tasks.time.ONE_HOUR import org.tasks.time.ONE_HOUR
import timber.log.Timber import timber.log.Timber
import java.util.TimeZone import java.util.TimeZone
@ -30,8 +31,8 @@ class GCalHelper @Inject constructor(
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val preferences: Preferences, private val preferences: Preferences,
private val permissionChecker: PermissionChecker, private val permissionChecker: PermissionChecker,
private val calendarEventProvider: CalendarEventProvider) { private val calendarEventProvider: CalendarEventProvider,
) {
private val cr: ContentResolver = context.contentResolver private val cr: ContentResolver = context.contentResolver
private suspend fun getTaskEventUri(task: Task) = private suspend fun getTaskEventUri(task: Task) =
@ -109,7 +110,7 @@ class GCalHelper @Inject constructor(
}) })
updateValues.put(CalendarContract.Events.DESCRIPTION, task.notes) updateValues.put(CalendarContract.Events.DESCRIPTION, task.notes)
createStartAndEndDate(task, updateValues) createStartAndEndDate(task, updateValues)
cr.update(Uri.parse(uri), updateValues, null, null) cr.update(uri.toUri(), updateValues, null, null)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to update calendar: %s [%s]", uri, task) Timber.e(e, "Failed to update calendar: %s [%s]", uri, task)
} }
@ -117,10 +118,10 @@ class GCalHelper @Inject constructor(
suspend fun rescheduleRepeatingTask(task: Task) { suspend fun rescheduleRepeatingTask(task: Task) {
val taskUri = getTaskEventUri(task) val taskUri = getTaskEventUri(task)
if (isNullOrEmpty(taskUri)) { if (taskUri.isNullOrBlank()) {
return return
} }
val eventUri = Uri.parse(taskUri) val eventUri = taskUri.toUri()
val event = calendarEventProvider.getEvent(eventUri) val event = calendarEventProvider.getEvent(eventUri)
if (event == null) { if (event == null) {
task.calendarURI = "" task.calendarURI = ""
@ -134,11 +135,6 @@ class GCalHelper @Inject constructor(
private fun createStartAndEndDate(task: Task, values: ContentValues) { private fun createStartAndEndDate(task: Task, values: ContentValues) {
val dueDate = task.dueDate val dueDate = task.dueDate
val tzCorrectedDueDate = dueDate + TimeZone.getDefault().getOffset(dueDate)
val tzCorrectedDueDateNow = currentTimeMillis() + TimeZone.getDefault().getOffset(
currentTimeMillis()
)
// FIXME: doesn't respect timezones, see story 17443653
if (task.hasDueDate()) { if (task.hasDueDate()) {
if (task.hasDueTime()) { if (task.hasDueTime()) {
var estimatedTime = task.estimatedSeconds * 1000.toLong() var estimatedTime = task.estimatedSeconds * 1000.toLong()
@ -152,24 +148,19 @@ class GCalHelper @Inject constructor(
values.put(CalendarContract.Events.DTSTART, dueDate - estimatedTime) values.put(CalendarContract.Events.DTSTART, dueDate - estimatedTime)
values.put(CalendarContract.Events.DTEND, dueDate) values.put(CalendarContract.Events.DTEND, dueDate)
} }
// setting a duetime to a previously timeless event requires explicitly setting allDay=0
values.put(CalendarContract.Events.ALL_DAY, "0") values.put(CalendarContract.Events.ALL_DAY, "0")
values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
} else { } else {
values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDate) val utcMidnight = DateTime(dueDate).toUTC().startOfDay()
values.put(CalendarContract.Events.DTEND, tzCorrectedDueDate) values.put(CalendarContract.Events.DTSTART, utcMidnight.millis)
values.put(CalendarContract.Events.ALL_DAY, "1") values.put(CalendarContract.Events.DTEND, utcMidnight.plusDays(1).millis)
}
} else {
values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDateNow)
values.put(CalendarContract.Events.DTEND, tzCorrectedDueDateNow)
values.put(CalendarContract.Events.ALL_DAY, "1") values.put(CalendarContract.Events.ALL_DAY, "1")
}
if ("1" == values[CalendarContract.Events.ALL_DAY]) {
values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC) values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC)
}
} else { } else {
values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) Timber.w("Not creating calendar event, task has no due date: %s", task)
} }
} }
companion object { companion object {

@ -7,7 +7,7 @@ package com.todoroo.astrid.gtasks
import com.google.api.services.tasks.model.TaskList import com.google.api.services.tasks.model.TaskList
import com.todoroo.astrid.service.TaskDeleter import com.todoroo.astrid.service.TaskDeleter
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
@ -16,7 +16,7 @@ import javax.inject.Inject
class GtasksListService @Inject constructor( class GtasksListService @Inject constructor(
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
) { ) {
/** /**
@ -55,6 +55,6 @@ class GtasksListService @Inject constructor(
for (listId in previousLists) { for (listId in previousLists) {
taskDeleter.delete(caldavDao.getCalendarById(listId)!!) taskDeleter.delete(caldavDao.getCalendarById(listId)!!)
} }
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
} }

@ -7,7 +7,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.VtodoCache import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.DeletionDao import org.tasks.data.dao.DeletionDao
import org.tasks.data.dao.LocationDao import org.tasks.data.dao.LocationDao
@ -28,7 +28,7 @@ class TaskDeleter @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val deletionDao: DeletionDao, private val deletionDao: DeletionDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val syncAdapters: SyncAdapters, private val syncAdapters: SyncAdapters,
private val vtodoCache: VtodoCache, private val vtodoCache: VtodoCache,
private val notificationManager: NotificationManager, private val notificationManager: NotificationManager,
@ -50,7 +50,7 @@ class TaskDeleter @Inject constructor(
cleanup = { cleanup(it) } cleanup = { cleanup(it) }
) )
syncAdapters.sync() syncAdapters.sync()
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
taskDao.fetch(ids) taskDao.fetch(ids)
} }
@ -63,7 +63,7 @@ class TaskDeleter @Inject constructor(
ids = tasks, ids = tasks,
cleanup = { cleanup(it) } cleanup = { cleanup(it) }
) )
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
suspend fun delete(list: CaldavCalendar) { suspend fun delete(list: CaldavCalendar) {
@ -72,7 +72,7 @@ class TaskDeleter @Inject constructor(
caldavCalendar = list, caldavCalendar = list,
cleanup = { cleanup(it) } cleanup = { cleanup(it) }
) )
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
suspend fun delete(account: CaldavAccount) { suspend fun delete(account: CaldavAccount) {
@ -81,7 +81,7 @@ class TaskDeleter @Inject constructor(
caldavAccount = account, caldavAccount = account,
cleanup = { cleanup(it) } cleanup = { cleanup(it) }
) )
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun cleanup(tasks: List<Long>) { private suspend fun cleanup(tasks: List<Long>) {

@ -2,7 +2,7 @@ package com.todoroo.astrid.service
import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.gcal.GCalHelper import com.todoroo.astrid.gcal.GCalHelper
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.dao.AlarmDao import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao import org.tasks.data.dao.GoogleTaskDao
@ -24,7 +24,7 @@ import javax.inject.Inject
class TaskDuplicator @Inject constructor( class TaskDuplicator @Inject constructor(
private val gcalHelper: GCalHelper, private val gcalHelper: GCalHelper,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val tagDao: TagDao, private val tagDao: TagDao,
private val tagDataDao: TagDataDao, private val tagDataDao: TagDataDao,
private val googleTaskDao: GoogleTaskDao, private val googleTaskDao: GoogleTaskDao,
@ -44,7 +44,7 @@ class TaskDuplicator @Inject constructor(
.let { taskDao.fetch(it) } .let { taskDao.fetch(it) }
.filterNot { it.readOnly } .filterNot { it.readOnly }
.map { clone(it, it.parent) } .map { clone(it, it.parent) }
.also { localBroadcastManager.broadcastRefresh() } .also { refreshBroadcaster.broadcastRefresh() }
} }
private suspend fun clone(task: Task, parentId: Long): Task { private suspend fun clone(task: Task, parentId: Long): Task {

@ -1,6 +1,6 @@
package com.todoroo.astrid.service package com.todoroo.astrid.service
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.VtodoCache import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao import org.tasks.data.dao.GoogleTaskDao
@ -22,7 +22,7 @@ class TaskMover @Inject constructor(
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val googleTaskDao: GoogleTaskDao, private val googleTaskDao: GoogleTaskDao,
private val preferences: Preferences, private val preferences: Preferences,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val syncAdapters: SyncAdapters, private val syncAdapters: SyncAdapters,
private val vtodoCache: VtodoCache, private val vtodoCache: VtodoCache,
) { ) {
@ -63,7 +63,7 @@ class TaskMover @Inject constructor(
taskIds.dbchunk().forEach { taskIds.dbchunk().forEach {
taskDao.touch(it) taskDao.touch(it)
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
syncAdapters.sync() syncAdapters.sync()
} }

@ -7,13 +7,14 @@ import android.content.IntentFilter
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.todoroo.astrid.api.AstridApiConstants import com.todoroo.astrid.api.AstridApiConstants
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.widget.AppWidgetManager import org.tasks.widget.AppWidgetManager
import javax.inject.Inject import javax.inject.Inject
class LocalBroadcastManager @Inject constructor( class LocalBroadcastManager @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
private val appWidgetManager: AppWidgetManager, private val appWidgetManager: AppWidgetManager,
) { ): RefreshBroadcaster {
private val localBroadcastManager = LocalBroadcastManager.getInstance(context) private val localBroadcastManager = LocalBroadcastManager.getInstance(context)
fun registerRefreshReceiver(broadcastReceiver: BroadcastReceiver?) { fun registerRefreshReceiver(broadcastReceiver: BroadcastReceiver?) {
@ -23,7 +24,6 @@ class LocalBroadcastManager @Inject constructor(
fun registerRefreshListReceiver(broadcastReceiver: BroadcastReceiver?) { fun registerRefreshListReceiver(broadcastReceiver: BroadcastReceiver?) {
val intentFilter = IntentFilter() val intentFilter = IntentFilter()
intentFilter.addAction(REFRESH) intentFilter.addAction(REFRESH)
intentFilter.addAction(REFRESH_LIST)
localBroadcastManager.registerReceiver(broadcastReceiver!!, intentFilter) localBroadcastManager.registerReceiver(broadcastReceiver!!, intentFilter)
} }
@ -42,15 +42,11 @@ class LocalBroadcastManager @Inject constructor(
) )
} }
fun broadcastRefresh() { override fun broadcastRefresh() {
localBroadcastManager.sendBroadcast(Intent(REFRESH)) localBroadcastManager.sendBroadcast(Intent(REFRESH))
appWidgetManager.updateWidgets() appWidgetManager.updateWidgets()
} }
fun broadcastRefreshList() {
localBroadcastManager.sendBroadcast(Intent(REFRESH_LIST))
}
fun broadcastPreferenceRefresh() { fun broadcastPreferenceRefresh() {
localBroadcastManager.sendBroadcast(Intent(REFRESH_PREFERENCES)) localBroadcastManager.sendBroadcast(Intent(REFRESH_PREFERENCES))
} }
@ -80,7 +76,6 @@ class LocalBroadcastManager @Inject constructor(
companion object { companion object {
const val REFRESH = "${BuildConfig.APPLICATION_ID}.REFRESH" const val REFRESH = "${BuildConfig.APPLICATION_ID}.REFRESH"
const val REFRESH_LIST = "${BuildConfig.APPLICATION_ID}.REFRESH_LIST"
private const val TASK_COMPLETED = "${BuildConfig.APPLICATION_ID}.REPEAT" private const val TASK_COMPLETED = "${BuildConfig.APPLICATION_ID}.REPEAT"
private const val REFRESH_PURCHASES = "${BuildConfig.APPLICATION_ID}.REFRESH_PURCHASES" private const val REFRESH_PURCHASES = "${BuildConfig.APPLICATION_ID}.REFRESH_PURCHASES"
private const val REFRESH_PREFERENCES = "${BuildConfig.APPLICATION_ID}.REFRESH_PREFERENCES" private const val REFRESH_PREFERENCES = "${BuildConfig.APPLICATION_ID}.REFRESH_PREFERENCES"

@ -6,6 +6,7 @@ import android.app.ApplicationExitInfo
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi

@ -28,7 +28,7 @@ import com.todoroo.astrid.api.TextInputCriterion
import com.todoroo.astrid.core.CriterionInstance import com.todoroo.astrid.core.CriterionInstance
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.Strings import org.tasks.Strings
import org.tasks.compose.DeleteButton import org.tasks.compose.DeleteButton
@ -56,7 +56,7 @@ import javax.inject.Inject
class FilterSettingsActivity : BaseListSettingsActivity() { class FilterSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var filterDao: FilterDao @Inject lateinit var filterDao: FilterDao
@Inject lateinit var filterCriteriaProvider: FilterCriteriaProvider @Inject lateinit var filterCriteriaProvider: FilterCriteriaProvider
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
private val viewModel: FilterSettingsViewModel by viewModels() private val viewModel: FilterSettingsViewModel by viewModels()
@ -128,7 +128,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
} else { } else {
filterDao.update(f) filterDao.update(f)
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)

@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
@ -35,7 +35,7 @@ import javax.inject.Inject
class GoogleTaskListSettingsActivity : BaseListSettingsActivity() { class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var caldavDao: CaldavDao @Inject lateinit var caldavDao: CaldavDao
@Inject lateinit var taskDeleter: TaskDeleter @Inject lateinit var taskDeleter: TaskDeleter
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
private val account: CaldavAccount private val account: CaldavAccount
get() = intent.getParcelableExtra(EXTRA_CALDAV_ACCOUNT)!! get() = intent.getParcelableExtra(EXTRA_CALDAV_ACCOUNT)!!
@ -122,7 +122,7 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
icon = baseViewModel.icon icon = baseViewModel.icon
) )
) )
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)

@ -140,7 +140,7 @@ class NavigationDrawerCustomization : ThemedInjectingAppCompatActivity(), Toolba
private inner class RefreshReceiver : BroadcastReceiver() { private inner class RefreshReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
val action = intent?.action val action = intent?.action
if (LocalBroadcastManager.REFRESH == action || LocalBroadcastManager.REFRESH_LIST == action) { if (LocalBroadcastManager.REFRESH == action) {
updateFilters() updateFilters()
} }
} }

@ -27,7 +27,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.compose.Constants import org.tasks.compose.Constants
@ -60,7 +60,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(),
@Inject lateinit var map: MapFragment @Inject lateinit var map: MapFragment
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@Inject lateinit var locale: Locale @Inject lateinit var locale: Locale
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
private lateinit var place: Place private lateinit var place: Place
override val defaultIcon = TasksIcons.PLACE override val defaultIcon = TasksIcons.PLACE
@ -172,7 +172,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(),
radius = sliderPos.floatValue.roundToInt(), radius = sliderPos.floatValue.roundToInt(),
) )
locationDao.update(place) locationDao.update(place)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)
@ -190,7 +190,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(),
locationDao.deleteGeofencesByPlace(place.uid!!) locationDao.deleteGeofencesByPlace(place.uid!!)
locationDao.delete(place) locationDao.delete(place)
setResult(Activity.RESULT_OK, Intent(TaskListFragment.ACTION_DELETED)) setResult(Activity.RESULT_OK, Intent(TaskListFragment.ACTION_DELETED))
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
finish() finish()
} }

@ -12,7 +12,7 @@ import androidx.activity.compose.setContent
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.data.dao.TagDao import org.tasks.data.dao.TagDao
@ -28,7 +28,7 @@ import javax.inject.Inject
class TagSettingsActivity : BaseListSettingsActivity() { class TagSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var tagDataDao: TagDataDao @Inject lateinit var tagDataDao: TagDataDao
@Inject lateinit var tagDao: TagDao @Inject lateinit var tagDao: TagDao
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
private lateinit var tagData: TagData private lateinit var tagData: TagData
private val isNewTag: Boolean private val isNewTag: Boolean
@ -88,7 +88,7 @@ class TagSettingsActivity : BaseListSettingsActivity() {
) )
.let { it.copy(id = tagDataDao.insert(it)) } .let { it.copy(id = tagDataDao.insert(it)) }
.let { .let {
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent().putExtra(MainActivity.OPEN_FILTER, TagFilter(it)) Intent().putExtra(MainActivity.OPEN_FILTER, TagFilter(it))
@ -104,7 +104,7 @@ class TagSettingsActivity : BaseListSettingsActivity() {
.let { .let {
tagDataDao.update(it) tagDataDao.update(it)
tagDao.rename(it.remoteId!!, newName) tagDao.rename(it.remoteId!!, newName)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)

@ -19,7 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.caldav.VtodoCache import org.tasks.caldav.VtodoCache
import org.tasks.data.GoogleTaskAccount import org.tasks.data.GoogleTaskAccount
@ -65,7 +65,7 @@ class TasksJsonImporter @Inject constructor(
private val userActivityDao: UserActivityDao, private val userActivityDao: UserActivityDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val locationDao: LocationDao, private val locationDao: LocationDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val alarmDao: AlarmDao, private val alarmDao: AlarmDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val filterDao: FilterDao, private val filterDao: FilterDao,
@ -110,7 +110,7 @@ class TasksJsonImporter @Inject constructor(
} catch (e: IOException) { } catch (e: IOException) {
Timber.e(e) Timber.e(e)
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
result result
} }

@ -15,7 +15,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -35,7 +34,6 @@ import org.tasks.data.entity.CaldavAccount.Companion.SERVER_TASKS
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
import org.tasks.themes.colorOn
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -72,7 +70,7 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
val openDialog = rememberSaveable { mutableStateOf(false) } val openDialog = rememberSaveable { mutableStateOf(false) }
ShareInviteDialog( ShareInviteDialog(
openDialog, openDialog,
email = caldavAccount.serverType != SERVER_OWNCLOUD email = caldavAccount.serverType !in listOf(SERVER_OWNCLOUD, SERVER_NEXTCLOUD),
) { input -> ) { input ->
lifecycleScope.launch { lifecycleScope.launch {
share(input) share(input)

@ -12,6 +12,8 @@ import org.tasks.data.UUIDHelper
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.PrincipalDao import org.tasks.data.dao.PrincipalDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE
import org.tasks.data.entity.CaldavCalendar.Companion.INVITE_UNKNOWN import org.tasks.data.entity.CaldavCalendar.Companion.INVITE_UNKNOWN
@ -96,10 +98,10 @@ class CaldavCalendarViewModel @Inject constructor(
list: CaldavCalendar, list: CaldavCalendar,
input: String input: String
) = doRequest { ) = doRequest {
val href = if (account.serverType == CaldavAccount.SERVER_OWNCLOUD) val href = when (account.serverType) {
"principal:principals/users/$input" SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> "principal:principals/users/$input"
else else -> "mailto:$input"
"mailto:$input" }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
provider.forAccount(account, list.url!!).share(account, href) provider.forAccount(account, list.url!!).share(account, href)
} }

@ -225,8 +225,8 @@ open class CaldavClient(
href: String, href: String,
) { ) {
when (account.serverType) { when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> shareSabredav(href) SERVER_TASKS, SERVER_SABREDAV -> shareSabredav(href)
SERVER_OWNCLOUD -> shareOwncloud(href) SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> shareOwncloud(href)
else -> throw IllegalArgumentException() else -> throw IllegalArgumentException()
} }
} }
@ -265,8 +265,8 @@ open class CaldavClient(
href: String, href: String,
) { ) {
when (account.serverType) { when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> removeSabrePrincipal(calendar, href) SERVER_TASKS, SERVER_SABREDAV -> removeSabrePrincipal(calendar, href)
SERVER_OWNCLOUD -> removeOwncloudPrincipal(calendar, href) SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> removeOwncloudPrincipal(calendar, href)
else -> throw IllegalArgumentException() else -> throw IllegalArgumentException()
} }
} }

@ -32,11 +32,11 @@ import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.iCalendar.Companion.fromVtodo import org.tasks.caldav.iCalendar.Companion.fromVtodo
import org.tasks.caldav.property.CalendarIcon import org.tasks.caldav.property.CalendarIcon
import org.tasks.caldav.property.Invite import org.tasks.caldav.property.Invite
@ -56,6 +56,7 @@ import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.PrincipalDao import org.tasks.data.dao.PrincipalDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.ERROR_UNAUTHORIZED import org.tasks.data.entity.CaldavAccount.Companion.ERROR_UNAUTHORIZED
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OPEN_XCHANGE import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OPEN_XCHANGE
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV
@ -84,7 +85,7 @@ class CaldavSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val inventory: Inventory, private val inventory: Inventory,
private val firebase: Firebase, private val firebase: Firebase,
@ -188,7 +189,7 @@ class CaldavSynchronizer @Inject constructor(
icon = icon ?: calendar.icon, icon = icon ?: calendar.icon,
) )
caldavDao.update(calendar) caldavDao.update(calendar)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
resource resource
.principals(account, calendar) .principals(account, calendar)
@ -203,7 +204,11 @@ class CaldavSynchronizer @Inject constructor(
private fun getServerType(account: CaldavAccount, headers: Headers) = when { private fun getServerType(account: CaldavAccount, headers: Headers) = when {
account.isTasksOrg -> SERVER_TASKS account.isTasksOrg -> SERVER_TASKS
headers["DAV"]?.contains("oc-resource-sharing") == true -> SERVER_OWNCLOUD headers["DAV"]?.contains("oc-resource-sharing") == true ->
if (headers["DAV"]?.let { it.contains("nextcloud-") || it.contains("nc-") } == true)
SERVER_NEXTCLOUD
else
SERVER_OWNCLOUD
headers["x-sabre-version"]?.isNotBlank() == true -> SERVER_SABREDAV headers["x-sabre-version"]?.isNotBlank() == true -> SERVER_SABREDAV
headers["server"] == "Openexchange WebDAV" -> SERVER_OPEN_XCHANGE headers["server"] == "Openexchange WebDAV" -> SERVER_OPEN_XCHANGE
else -> SERVER_UNKNOWN else -> SERVER_UNKNOWN
@ -220,7 +225,7 @@ class CaldavSynchronizer @Inject constructor(
} }
account.error = message account.error = message
caldavDao.update(account) caldavDao.update(account)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
if (!isNullOrEmpty(message)) { if (!isNullOrEmpty(message)) {
Timber.e(message) Timber.e(message)
} }
@ -297,7 +302,7 @@ class CaldavSynchronizer @Inject constructor(
caldavDao.update(caldavCalendar) caldavDao.update(caldavCalendar)
Timber.d("Updating parents for ${caldavCalendar.uuid}") Timber.d("Updating parents for ${caldavCalendar.uuid}")
caldavDao.updateParents(caldavCalendar.uuid!!) caldavDao.updateParents(caldavCalendar.uuid!!)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun pushLocalChanges( private suspend fun pushLocalChanges(
@ -325,9 +330,17 @@ class CaldavSynchronizer @Inject constructor(
caldavTask: CaldavTask caldavTask: CaldavTask
): Boolean { ): Boolean {
try { try {
if (!isNullOrEmpty(caldavTask.obj)) { val objectId = caldavTask.obj
?: run {
Timber.e("null obj for caldavTask.id=${caldavTask.id} task.id=${caldavTask.task}")
caldavTask.obj = caldavTask.remoteId?.let { "$it.ics" }
caldavTask.obj
}
if (objectId?.isNotBlank() == true) {
val remote = DavResource( val remote = DavResource(
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build()) httpClient = httpClient,
location = httpUrl.newBuilder().addPathSegment(objectId).build(),
)
remote.delete(null) {} remote.delete(null) {}
} }
} catch (e: HttpException) { } catch (e: HttpException) {
@ -351,8 +364,8 @@ class CaldavSynchronizer @Inject constructor(
httpClient: OkHttpClient, httpClient: OkHttpClient,
httpUrl: HttpUrl httpUrl: HttpUrl
) { ) {
Timber.d("pushing %s", task)
val caldavTask = caldavDao.getTask(task.id) ?: return val caldavTask = caldavDao.getTask(task.id) ?: return
Timber.d("pushing caldavTask=$caldavTask task=$task")
if (task.isDeleted) { if (task.isDeleted) {
if (deleteRemoteResource(httpClient, httpUrl, calendar, caldavTask)) { if (deleteRemoteResource(httpClient, httpUrl, calendar, caldavTask)) {
taskDeleter.delete(task) taskDeleter.delete(task)
@ -361,9 +374,19 @@ class CaldavSynchronizer @Inject constructor(
} }
val data = iCal.toVtodo(account, calendar, caldavTask, task) val data = iCal.toVtodo(account, calendar, caldavTask, task)
val requestBody = data.toRequestBody(contentType = MIME_ICALENDAR) val requestBody = data.toRequestBody(contentType = MIME_ICALENDAR)
val objPath = caldavTask.obj
?: run {
Timber.e("null obj for caldavTask.id=${caldavTask.id} task.id=${task.id}")
caldavTask.obj = caldavTask.remoteId?.let { "$it.ics" }
caldavTask.obj
}
?: throw IllegalStateException("Push failed - missing UUID")
try { try {
val remote = DavResource( val remote = DavResource(
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build()) httpClient = httpClient,
location = httpUrl.newBuilder().addPathSegment(objPath).build(),
)
remote.put(requestBody) { remote.put(requestBody) {
if (it.isSuccessful) { if (it.isSuccessful) {
fromResponse(it)?.eTag?.takeIf(String::isNotBlank)?.let { etag -> fromResponse(it)?.eTag?.takeIf(String::isNotBlank)?.let { etag ->

@ -25,10 +25,11 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -62,22 +63,53 @@ import java.util.concurrent.TimeUnit
@ExperimentalComposeUiApi @ExperimentalComposeUiApi
object AddReminderDialog { object AddReminderDialog {
// Helper functions for converting between Alarm properties and UI state
private fun unitIndexToMillis(unitIndex: Int): Long = when (unitIndex) {
1 -> TimeUnit.HOURS.toMillis(1)
2 -> TimeUnit.DAYS.toMillis(1)
3 -> TimeUnit.DAYS.toMillis(7)
else -> TimeUnit.MINUTES.toMillis(1)
}
private fun timeToAmountAndUnit(time: Long): Pair<Int, Int> {
val absTime = kotlin.math.abs(time)
return when {
absTime == 0L -> 0 to 0 // Default to minutes when time is 0
absTime % TimeUnit.DAYS.toMillis(7) == 0L ->
(absTime / TimeUnit.DAYS.toMillis(7)).toInt() to 3
absTime % TimeUnit.DAYS.toMillis(1) == 0L ->
(absTime / TimeUnit.DAYS.toMillis(1)).toInt() to 2
absTime % TimeUnit.HOURS.toMillis(1) == 0L ->
(absTime / TimeUnit.HOURS.toMillis(1)).toInt() to 1
else ->
(absTime / TimeUnit.MINUTES.toMillis(1)).toInt() to 0
}
}
@Composable @Composable
fun AddRandomReminderDialog( fun AddRandomReminderDialog(
viewState: ViewState, alarm: Alarm?,
addAlarm: (Alarm) -> Unit, updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
val time = rememberSaveable { mutableStateOf(15) } // Create working copy from alarm or use defaults
val units = rememberSaveable { mutableStateOf(0) } var workingCopy by rememberSaveable {
if (viewState.showRandomDialog) { mutableStateOf(alarm ?: Alarm(time = 15 * TimeUnit.MINUTES.toMillis(1), type = TYPE_RANDOM))
}
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { AddRandomReminder(time, units) }, text = {
AddRandomReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it > 0 }?.let { i -> val (amount, _) = timeToAmountAndUnit(workingCopy.time)
addAlarm(Alarm(time = i * units.millis, type = TYPE_RANDOM)) if (amount > 0) {
updateAlarm(workingCopy)
closeDialog() closeDialog()
} }
}) })
@ -89,52 +121,40 @@ object AddReminderDialog {
) )
}, },
) )
} else {
time.value = 15
units.value = 0
}
} }
@Composable @Composable
fun AddCustomReminderDialog( fun AddCustomReminderDialog(
viewState: ViewState, alarm: Alarm?,
addAlarm: (Alarm) -> Unit, updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
val openDialog = viewState.showCustomDialog // Create working copy from alarm or use defaults
val time = rememberSaveable { mutableStateOf(15) } var workingCopy by rememberSaveable {
val units = rememberSaveable { mutableStateOf(0) } mutableStateOf(
val openRecurringDialog = rememberSaveable { mutableStateOf(false) } alarm ?: Alarm(
val interval = rememberSaveable { mutableStateOf(0) } time = -1 * 15 * TimeUnit.MINUTES.toMillis(1),
val recurringUnits = rememberSaveable { mutableStateOf(0) } type = TYPE_REL_END
val repeat = rememberSaveable { mutableStateOf(0) } )
if (openDialog) { )
if (!openRecurringDialog.value) { }
var showRecurringDialog by rememberSaveable { mutableStateOf(false) }
if (!showRecurringDialog) {
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { text = {
AddCustomReminder( AddCustomReminder(
time, alarm = workingCopy,
units, updateAlarm = { workingCopy = it },
interval, showRecurring = { showRecurringDialog = true }
recurringUnits,
repeat,
showRecurring = {
openRecurringDialog.value = true
}
) )
}, },
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it >= 0 }?.let { i -> val (amount, _) = timeToAmountAndUnit(workingCopy.time)
addAlarm( if (amount >= 0) {
Alarm( updateAlarm(workingCopy)
time = -1 * i * units.millis,
type = TYPE_REL_END,
repeat = repeat.value,
interval = interval.value * recurringUnits.millis
)
)
closeDialog() closeDialog()
} }
}) })
@ -147,56 +167,51 @@ object AddReminderDialog {
}, },
) )
} }
if (showRecurringDialog) {
AddRepeatReminderDialog( AddRepeatReminderDialog(
openDialog = openRecurringDialog, alarm = workingCopy,
initialInterval = interval.value, updateAlarm = { workingCopy = it },
initialUnits = recurringUnits.value, closeDialog = { showRecurringDialog = false }
initialRepeat = repeat.value,
selected = { i, u, r ->
interval.value = i
recurringUnits.value = u
repeat.value = r
}
) )
} else {
time.value = 15
units.value = 0
interval.value = 0
recurringUnits.value = 0
repeat.value = 0
} }
} }
@Composable @Composable
fun AddRepeatReminderDialog( fun AddRepeatReminderDialog(
openDialog: MutableState<Boolean>, alarm: Alarm,
initialInterval: Int, updateAlarm: (Alarm) -> Unit,
initialUnits: Int, closeDialog: () -> Unit,
initialRepeat: Int,
selected: (Int, Int, Int) -> Unit,
) { ) {
val interval = rememberSaveable { mutableStateOf(initialInterval) } // Create working copy with defaults if no recurrence set
val units = rememberSaveable { mutableStateOf(initialUnits) } var workingCopy by rememberSaveable {
val repeat = rememberSaveable { mutableStateOf(initialRepeat) } mutableStateOf(
val closeDialog = { if (alarm.interval == 0L && alarm.repeat == 0) {
openDialog.value = false // Default to 15 minutes, 4 times
alarm.copy(
interval = 15 * TimeUnit.MINUTES.toMillis(1),
repeat = 4
)
} else {
alarm
}
)
} }
if (openDialog.value) {
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { text = {
AddRecurringReminder( AddRecurringReminder(
openDialog.value, alarm = workingCopy,
interval, updateAlarm = { workingCopy = it }
units,
repeat,
) )
}, },
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
if (interval.value > 0 && repeat.value > 0) { val (intervalAmount, _) = timeToAmountAndUnit(workingCopy.interval)
selected(interval.value, units.value, repeat.value) if (intervalAmount > 0 && workingCopy.repeat > 0) {
openDialog.value = false updateAlarm(workingCopy)
closeDialog()
} }
}) })
}, },
@ -207,19 +222,18 @@ object AddReminderDialog {
) )
}, },
) )
} else {
interval.value = initialInterval.takeIf { it > 0 } ?: 15
units.value = initialUnits
repeat.value = initialRepeat.takeIf { it > 0 } ?: 4
}
} }
@Composable @Composable
fun AddRandomReminder( fun AddRandomReminder(
time: MutableState<Int>, alarm: Alarm,
units: MutableState<Int>, updateAlarm: (Alarm) -> Unit,
) { ) {
val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time)
var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) }
val amount = if (alarm.time == 0L) 0 else (alarm.time / unitIndexToMillis(selectedUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -228,14 +242,27 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim()) CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim())
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
time, value = amount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = amt * unitIndexToMillis(selectedUnit)))
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow(index, option, time, units) RadioRow(
index = index,
option = option,
timeAmount = amount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(time = amount * unitIndexToMillis(newUnit)))
}
)
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(true, focusRequester)
} }
@ -243,14 +270,19 @@ object AddReminderDialog {
@Composable @Composable
fun AddCustomReminder( fun AddCustomReminder(
time: MutableState<Int>, alarm: Alarm,
units: MutableState<Int>, updateAlarm: (Alarm) -> Unit,
interval: MutableState<Int>,
recurringUnits: MutableState<Int>,
repeat: MutableState<Int>,
showRecurring: () -> Unit, showRecurring: () -> Unit,
) { ) {
val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time)
var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) }
val amount = if (alarm.time == 0L) 0 else kotlin.math.abs(alarm.time / unitIndexToMillis(selectedUnit)).toInt()
val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval)
val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(initialIntervalUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -259,7 +291,11 @@ object AddReminderDialog {
CenteredH6(resId = R.string.custom_notification) CenteredH6(resId = R.string.custom_notification)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
time, value = amount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = -1 * amt * unitIndexToMillis(selectedUnit)))
},
minValue = 0, minValue = 0,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -267,7 +303,17 @@ object AddReminderDialog {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow(index, option, time, units, R.string.alarm_before_due) RadioRow(
index = index,
option = option,
timeAmount = amount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(time = -1 * amount * unitIndexToMillis(newUnit)))
},
formatString = R.string.alarm_before_due
)
} }
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier Row(modifier = Modifier
@ -288,11 +334,11 @@ object AddReminderDialog {
), ),
) )
} }
val repeating = repeat.value > 0 && interval.value > 0 val repeating = alarm.repeat > 0 && intervalAmount > 0
val text = if (repeating) { val text = if (repeating) {
LocalContext.current.resources.getRepeatString( LocalContext.current.resources.getRepeatString(
repeat.value, alarm.repeat,
interval.value * recurringUnits.millis alarm.interval
) )
} else { } else {
stringResource(id = R.string.repeat_option_does_not_repeat) stringResource(id = R.string.repeat_option_does_not_repeat)
@ -305,11 +351,9 @@ object AddReminderDialog {
.align(CenterVertically) .align(CenterVertically)
) )
if (repeating) { if (repeating) {
ClearButton { ClearButton(onClick = {
repeat.value = 0 updateAlarm(alarm.copy(repeat = 0, interval = 0))
interval.value = 0 })
recurringUnits.value = 0
}
} }
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(true, focusRequester)
@ -318,12 +362,14 @@ object AddReminderDialog {
@Composable @Composable
fun AddRecurringReminder( fun AddRecurringReminder(
openDialog: Boolean, alarm: Alarm,
interval: MutableState<Int>, updateAlarm: (Alarm) -> Unit,
units: MutableState<Int>,
repeat: MutableState<Int>
) { ) {
val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval)
var selectedUnit by rememberSaveable { mutableStateOf(initialIntervalUnit) }
val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(selectedUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -332,24 +378,40 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim()) CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim())
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
time = interval, value = intervalAmount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(interval = amt * unitIndexToMillis(selectedUnit)))
},
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow(index, option, interval, units) RadioRow(
index = index,
option = option,
timeAmount = intervalAmount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(interval = intervalAmount * unitIndexToMillis(newUnit)))
}
)
} }
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
OutlinedIntInput( OutlinedIntInput(
time = repeat, value = alarm.repeat,
onValueChange = { newRepeat ->
updateAlarm(alarm.copy(repeat = newRepeat ?: 0))
},
modifier = Modifier.weight(0.5f), modifier = Modifier.weight(0.5f),
autoSelect = false, autoSelect = false,
) )
BodyText( BodyText(
text = LocalContext.current.resources.getQuantityString( text = LocalContext.current.resources.getQuantityString(
R.plurals.repeat_times, R.plurals.repeat_times,
repeat.value alarm.repeat
), ),
modifier = Modifier modifier = Modifier
.weight(0.5f) .weight(0.5f)
@ -357,7 +419,7 @@ object AddReminderDialog {
) )
} }
ShowKeyboard(openDialog, focusRequester) ShowKeyboard(true, focusRequester)
} }
} }
@ -367,14 +429,6 @@ object AddReminderDialog {
R.plurals.reminder_days, R.plurals.reminder_days,
R.plurals.reminder_week, R.plurals.reminder_week,
) )
private val MutableState<Int>.millis: Long
get() = when (value) {
1 -> TimeUnit.HOURS.toMillis(1)
2 -> TimeUnit.DAYS.toMillis(1)
3 -> TimeUnit.DAYS.toMillis(7)
else -> TimeUnit.MINUTES.toMillis(1)
}
} }
@ExperimentalComposeUiApi @ExperimentalComposeUiApi
@ -391,25 +445,48 @@ fun ShowKeyboard(visible: Boolean, focusRequester: FocusRequester) {
@Composable @Composable
fun OutlinedIntInput( fun OutlinedIntInput(
time: MutableState<Int>, value: Int?,
onValueChange: (Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
minValue: Int = 1, minValue: Int = 1,
autoSelect: Boolean = true, autoSelect: Boolean = true,
) { ) {
val value = rememberSaveable(stateSaver = TextFieldValue.Saver) { var textFieldValue by remember {
val text = time.value.toString()
mutableStateOf( mutableStateOf(
TextFieldValue( TextFieldValue(
text = text, text = value?.toString() ?: "",
selection = TextRange(0, if (autoSelect) text.length else 0) selection = if (autoSelect) {
TextRange(0, value?.toString()?.length ?: 0)
} else {
TextRange.Zero
}
) )
) )
} }
// Sync when external value changes, but don't interfere with user editing
LaunchedEffect(value) {
val currentParsedValue = textFieldValue.text.toIntOrNull()
// Only sync if the new value is different from what we currently parse to,
// and don't sync if the text field is empty (user is actively deleting)
if (currentParsedValue != value && textFieldValue.text.isNotEmpty()) {
val newText = value?.toString() ?: ""
textFieldValue = TextFieldValue(
text = newText,
selection = if (autoSelect) {
TextRange(0, newText.length)
} else {
textFieldValue.selection
}
)
}
}
OutlinedTextField( OutlinedTextField(
value = value.value, value = textFieldValue,
onValueChange = { onValueChange = {
value.value = it.copy(text = it.text.filter { t -> t.isDigit() }) textFieldValue = it.copy(text = it.text.filter { t -> t.isDigit() })
time.value = value.value.text.toIntOrNull() ?: 0 onValueChange(textFieldValue.text.toIntOrNull())
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier.padding(horizontal = 16.dp),
@ -419,7 +496,7 @@ fun OutlinedIntInput(
focusedBorderColor = MaterialTheme.colorScheme.onSurface, focusedBorderColor = MaterialTheme.colorScheme.onSurface,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurface, unfocusedBorderColor = MaterialTheme.colorScheme.onSurface,
), ),
isError = value.value.text.toIntOrNull()?.let { it < minValue } ?: true, isError = textFieldValue.text.toIntOrNull()?.let { it < minValue } ?: true,
) )
} }
@ -445,23 +522,24 @@ fun CenteredH6(text: String) {
fun RadioRow( fun RadioRow(
index: Int, index: Int,
option: Int, option: Int,
time: MutableState<Int>, timeAmount: Int,
units: MutableState<Int>, unitIndex: Int,
onUnitSelected: (Int) -> Unit,
formatString: Int? = null, formatString: Int? = null,
) { ) {
val optionString = LocalContext.current.resources.getQuantityString(option, time.value) val optionString = LocalContext.current.resources.getQuantityString(option, timeAmount)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { units.value = index } .clickable { onUnitSelected(index) }
) { ) {
RadioButton( RadioButton(
selected = index == units.value, selected = index == unitIndex,
onClick = { units.value = index }, onClick = { onUnitSelected(index) },
modifier = Modifier.align(CenterVertically) modifier = Modifier.align(CenterVertically)
) )
BodyText( BodyText(
text = if (index == units.value) { text = if (index == unitIndex) {
formatString formatString
?.let { stringResource(id = formatString, optionString) } ?.let { stringResource(id = formatString, optionString) }
?: optionString ?: optionString
@ -506,8 +584,14 @@ fun AddAlarmDialog(
dismiss() dismiss()
return return
} }
// TODO: if replacing custom alarm show custom picker TYPE_REL_END -> {
// TODO: prepopulate pickers with existing values if (viewState.replace.time < 0) {
// Custom reminder (before due)
addCustom()
dismiss()
return
}
}
} }
} }
CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) { CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) {
@ -555,11 +639,11 @@ fun AddAlarmDialog(
fun AddCustomReminderOne() = fun AddCustomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
time = remember { mutableStateOf(1) }, alarm = Alarm(
units = remember { mutableStateOf(0) }, time = -1 * TimeUnit.MINUTES.toMillis(1),
interval = remember { mutableStateOf(0) }, type = TYPE_REL_END
recurringUnits = remember { mutableStateOf(0) }, ),
repeat = remember { mutableStateOf(0) }, updateAlarm = {},
showRecurring = {}, showRecurring = {},
) )
} }
@ -571,11 +655,11 @@ fun AddCustomReminderOne() =
fun AddCustomReminder() = fun AddCustomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
time = remember { mutableStateOf(15) }, alarm = Alarm(
units = remember { mutableStateOf(1) }, time = -15 * TimeUnit.HOURS.toMillis(1),
interval = remember { mutableStateOf(0) }, type = TYPE_REL_END
recurringUnits = remember { mutableStateOf(0) }, ),
repeat = remember { mutableStateOf(0) }, updateAlarm = {},
showRecurring = {}, showRecurring = {},
) )
} }
@ -587,10 +671,13 @@ fun AddCustomReminder() =
fun AddRepeatingReminderOne() = fun AddRepeatingReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
openDialog = true, alarm = Alarm(
interval = remember { mutableStateOf(1) }, time = -1 * TimeUnit.MINUTES.toMillis(1),
units = remember { mutableStateOf(0) }, type = TYPE_REL_END,
repeat = remember { mutableStateOf(1) }, interval = TimeUnit.MINUTES.toMillis(1),
repeat = 1
),
updateAlarm = {},
) )
} }
@ -601,10 +688,13 @@ fun AddRepeatingReminderOne() =
fun AddRepeatingReminder() = fun AddRepeatingReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
openDialog = true, alarm = Alarm(
interval = remember { mutableStateOf(15) }, time = -15 * TimeUnit.HOURS.toMillis(1),
units = remember { mutableStateOf(1) }, type = TYPE_REL_END,
repeat = remember { mutableStateOf(4) }, interval = 15 * TimeUnit.HOURS.toMillis(1),
repeat = 4
),
updateAlarm = {},
) )
} }
@ -615,8 +705,11 @@ fun AddRepeatingReminder() =
fun AddRandomReminderOne() = fun AddRandomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
time = remember { mutableStateOf(1) }, alarm = Alarm(
units = remember { mutableStateOf(0) } time = TimeUnit.MINUTES.toMillis(1),
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }
@ -627,8 +720,11 @@ fun AddRandomReminderOne() =
fun AddRandomReminder() = fun AddRandomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
time = remember { mutableStateOf(15) }, alarm = Alarm(
units = remember { mutableStateOf(1) } time = 15 * TimeUnit.HOURS.toMillis(1),
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }

@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Abc import androidx.compose.material.icons.outlined.Abc
@ -54,6 +55,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -384,16 +386,31 @@ object FilterCondition {
Row { Row {
for (index in items.indices) { for (index in items.indices) {
val highlight = (index == selected.intValue) val highlight = (index == selected.intValue)
val color =
if (highlight) MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
OutlinedButton( OutlinedButton(
onClick = { selected.intValue = index }, onClick = { selected.intValue = index },
border = BorderStroke(1.dp, SolidColor(color.copy(alpha = 0.5f))), border = BorderStroke(
width = 1.dp,
brush = SolidColor(
if (highlight) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
}
)
),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
containerColor = color.copy(alpha = 0.2f), containerColor = if (highlight) {
contentColor = MaterialTheme.colorScheme.onBackground), MaterialTheme.colorScheme.primary
shape = RoundedCornerShape(Constants.HALF_KEYLINE) } else {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f)
},
contentColor = if (highlight) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onBackground
},
),
shape = RoundedCornerShape(Constants.HALF_KEYLINE),
) { ) {
Text(items[index]) Text(items[index])
} }
@ -484,6 +501,9 @@ object FilterCondition {
contentDescription = null contentDescription = null
) )
}, },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = MaterialTheme.typography.bodyMedium.copy( textStyle = MaterialTheme.typography.bodyMedium.copy(
textDirection = TextDirection.Content textDirection = TextDirection.Content
), ),

@ -106,23 +106,27 @@ fun AlarmRow(
dismiss = { vm.showAddAlarm(visible = false) }, dismiss = { vm.showAddAlarm(visible = false) },
) )
if (viewState.showCustomDialog) {
AddReminderDialog.AddCustomReminderDialog( AddReminderDialog.AddCustomReminderDialog(
viewState = viewState, alarm = viewState.replace,
addAlarm = { updateAlarm = {
viewState.replace?.let(deleteAlarm) viewState.replace?.let(deleteAlarm)
addAlarm(it) addAlarm(it)
}, },
closeDialog = { vm.showCustomDialog(visible = false) } closeDialog = { vm.showCustomDialog(visible = false) }
) )
}
if (viewState.showRandomDialog) {
AddReminderDialog.AddRandomReminderDialog( AddReminderDialog.AddRandomReminderDialog(
viewState = viewState, alarm = viewState.replace,
addAlarm = { updateAlarm = {
viewState.replace?.let(deleteAlarm) viewState.replace?.let(deleteAlarm)
addAlarm(it) addAlarm(it)
}, },
closeDialog = { vm.showRandomDialog(visible = false) } closeDialog = { vm.showRandomDialog(visible = false) }
) )
}
}, },
) )
} }

@ -5,10 +5,13 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -17,6 +20,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@ -46,6 +50,7 @@ fun ListSettingsScaffold(
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Scaffold( Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
topBar = { topBar = {
Column { Column {
val context = LocalContext.current val context = LocalContext.current

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
@ -24,6 +25,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -73,6 +75,9 @@ fun TitleInput(
color = LocalContentColor.current color = LocalContentColor.current
), ),
onValueChange = { setText(it) }, onValueChange = { setText(it) },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
cursorBrush = SolidColor(errorState), // SolidColor(LocalContentColor.current), cursorBrush = SolidColor(errorState), // SolidColor(LocalContentColor.current),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

@ -3,15 +3,14 @@ package org.tasks.data
import android.content.ContentUris import android.content.ContentUris
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import at.bitfire.ical4android.AndroidTask
import at.bitfire.ical4android.BatchOperation import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.BatchOperation.CpoBuilder.Companion.newInsert import at.bitfire.ical4android.BatchOperation.CpoBuilder.Companion.newInsert
import at.bitfire.ical4android.BatchOperation.CpoBuilder.Companion.newUpdate import at.bitfire.ical4android.BatchOperation.CpoBuilder.Companion.newUpdate
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.ICalendar import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.Task import at.bitfire.ical4android.Task
import at.bitfire.ical4android.UnknownProperty import at.bitfire.ical4android.UnknownProperty
import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues import at.bitfire.ical4android.util.MiscUtils.toValues
import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Parameter
import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.RelType
import net.fortuna.ical4j.model.parameter.Related import net.fortuna.ical4j.model.parameter.Related
@ -21,7 +20,7 @@ import org.tasks.data.OpenTaskDao.Companion.getLong
import java.util.Locale import java.util.Locale
import java.util.logging.Level import java.util.logging.Level
class MyAndroidTask() : AndroidTask(null) { class MyAndroidTask() : DmfsTask(null) {
constructor(cursor: Cursor) : this() { constructor(cursor: Cursor) : this() {
val values = cursor.toValues() val values = cursor.toValues()
@ -99,7 +98,7 @@ class MyAndroidTask() : AndroidTask(null) {
.withValue(TaskContract.Property.Alarm.MESSAGE, alarm.description?.value ?: alarm.summary) .withValue(TaskContract.Property.Alarm.MESSAGE, alarm.description?.value ?: alarm.summary)
.withValue(TaskContract.Property.Alarm.ALARM_TYPE, alarmType) .withValue(TaskContract.Property.Alarm.ALARM_TYPE, alarmType)
Ical4Android.log.log(Level.FINE, "Inserting alarm", builder.build()) logger.log(Level.FINE, "Inserting alarm", builder.build())
batch.add(builder) batch.add(builder)
} }
} }
@ -110,7 +109,7 @@ class MyAndroidTask() : AndroidTask(null) {
.withTaskId(TaskContract.Property.Category.TASK_ID, idxTask) .withTaskId(TaskContract.Property.Category.TASK_ID, idxTask)
.withValue(TaskContract.Property.Category.MIMETYPE, TaskContract.Property.Category.CONTENT_ITEM_TYPE) .withValue(TaskContract.Property.Category.MIMETYPE, TaskContract.Property.Category.CONTENT_ITEM_TYPE)
.withValue(TaskContract.Property.Category.CATEGORY_NAME, category) .withValue(TaskContract.Property.Category.CATEGORY_NAME, category)
Ical4Android.log.log(Level.FINE, "Inserting category", builder.build()) logger.log(Level.FINE, "Inserting category", builder.build())
batch.add(builder) batch.add(builder)
} }
} }
@ -130,7 +129,7 @@ class MyAndroidTask() : AndroidTask(null) {
.withValue(TaskContract.Property.Relation.MIMETYPE, TaskContract.Property.Relation.CONTENT_ITEM_TYPE) .withValue(TaskContract.Property.Relation.MIMETYPE, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)
.withValue(TaskContract.Property.Relation.RELATED_UID, relatedTo.value) .withValue(TaskContract.Property.Relation.RELATED_UID, relatedTo.value)
.withValue(TaskContract.Property.Relation.RELATED_TYPE, relType) .withValue(TaskContract.Property.Relation.RELATED_TYPE, relType)
Ical4Android.log.log(Level.FINE, "Inserting relation", builder.build()) logger.log(Level.FINE, "Inserting relation", builder.build())
batch.add(builder) batch.add(builder)
} }
} }
@ -138,7 +137,7 @@ class MyAndroidTask() : AndroidTask(null) {
private fun insertUnknownProperties(batch: MutableList<BatchOperation.CpoBuilder>, idxTask: Int?, uri: Uri) { private fun insertUnknownProperties(batch: MutableList<BatchOperation.CpoBuilder>, idxTask: Int?, uri: Uri) {
for (property in requireNotNull(task).unknownProperties) { for (property in requireNotNull(task).unknownProperties) {
if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) {
Ical4Android.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)") logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
return return
} }
@ -146,7 +145,7 @@ class MyAndroidTask() : AndroidTask(null) {
.withTaskId(TaskContract.Properties.TASK_ID, idxTask) .withTaskId(TaskContract.Properties.TASK_ID, idxTask)
.withValue(TaskContract.Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE) .withValue(TaskContract.Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE)
.withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property)) .withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property))
Ical4Android.log.log(Level.FINE, "Inserting unknown property", builder.build()) logger.log(Level.FINE, "Inserting unknown property", builder.build())
batch.add(builder) batch.add(builder)
} }
} }

@ -86,7 +86,7 @@ class FilterPickerViewModel @Inject constructor(
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
localBroadcastManager.broadcastRefreshList() localBroadcastManager.broadcastRefresh()
} }
fun getIcon(filter: Filter): String? = filter.getIcon(inventory) fun getIcon(filter: Filter): String? = filter.getIcon(inventory)

@ -8,8 +8,8 @@ import com.etebase.client.Item
import com.etebase.client.ItemMetadata import com.etebase.client.ItemMetadata
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.CaldavTask
import org.tasks.time.DateTimeUtils2.currentTimeMillis import org.tasks.time.DateTimeUtils2.currentTimeMillis
import timber.log.Timber import timber.log.Timber
@ -64,7 +64,14 @@ class EtebaseClient(
suspend fun updateItem(collection: Collection, task: CaldavTask, content: ByteArray): Item { suspend fun updateItem(collection: Collection, task: CaldavTask, content: ByteArray): Item {
val itemManager = etebase.collectionManager.getItemManager(collection) val itemManager = etebase.collectionManager.getItemManager(collection)
val item = cache.itemGet(itemManager, collection.uid, task.obj!!) val obj = task.obj
?: run {
Timber.e("null obj for caldavTask.id=${task.id}")
task.obj = task.remoteId
task.obj
}
?: throw IllegalStateException("Update failed - missing UUID")
val item = cache.itemGet(itemManager, collection.uid, obj)
?: itemManager ?: itemManager
.create(ItemMetadata().apply { name = task.remoteId!! }, "") .create(ItemMetadata().apply { name = task.remoteId!! }, "")
.apply { .apply {
@ -78,7 +85,14 @@ class EtebaseClient(
suspend fun deleteItem(collection: Collection, task: CaldavTask): Item? { suspend fun deleteItem(collection: Collection, task: CaldavTask): Item? {
val itemManager = etebase.collectionManager.getItemManager(collection) val itemManager = etebase.collectionManager.getItemManager(collection)
return cache.itemGet(itemManager, collection.uid, task.obj!!) val objId = task.obj
?: run {
Timber.e("null obj for caldavTask.id=${task.id}")
task.obj = task.remoteId
task.obj
}
?: return null
return cache.itemGet(itemManager, collection.uid, objId)
?.takeIf { !it.isDeleted } ?.takeIf { !it.isDeleted }
?.apply { ?.apply {
meta = updateMtime(meta) meta = updateMtime(meta)

@ -14,7 +14,7 @@ import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.ProdId
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
@ -32,7 +32,7 @@ import javax.inject.Inject
class EtebaseSynchronizer @Inject constructor( class EtebaseSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val inventory: Inventory, private val inventory: Inventory,
private val clientProvider: EtebaseClientProvider, private val clientProvider: EtebaseClientProvider,
@ -98,7 +98,7 @@ class EtebaseSynchronizer @Inject constructor(
calendar.name = meta.name calendar.name = meta.name
calendar.color = color calendar.color = color
caldavDao.update(calendar) caldavDao.update(calendar)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
fetchChanges(account, client, calendar, collection) fetchChanges(account, client, calendar, collection)
pushLocalChanges(account, client, calendar, collection) pushLocalChanges(account, client, calendar, collection)
@ -112,7 +112,7 @@ class EtebaseSynchronizer @Inject constructor(
private suspend fun setError(account: CaldavAccount, message: String?) { private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message account.error = message
caldavDao.update(account) caldavDao.update(account)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
if (!isNullOrEmpty(message)) { if (!isNullOrEmpty(message)) {
Timber.e(message) Timber.e(message)
} }
@ -137,7 +137,7 @@ class EtebaseSynchronizer @Inject constructor(
caldavDao.update(caldavCalendar) caldavDao.update(caldavCalendar)
Timber.d("Updating parents for ${caldavCalendar.uuid}") Timber.d("Updating parents for ${caldavCalendar.uuid}")
caldavDao.updateParents(caldavCalendar.uuid!!) caldavDao.updateParents(caldavCalendar.uuid!!)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun pushLocalChanges( private suspend fun pushLocalChanges(

@ -16,7 +16,7 @@ import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms
import com.todoroo.astrid.service.TaskDeleter import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
@ -56,7 +56,7 @@ class GoogleTaskSynchronizer @Inject constructor(
private val defaultFilterProvider: DefaultFilterProvider, private val defaultFilterProvider: DefaultFilterProvider,
private val permissionChecker: PermissionChecker, private val permissionChecker: PermissionChecker,
private val googleAccountManager: GoogleAccountManager, private val googleAccountManager: GoogleAccountManager,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val invokers: InvokerFactory, private val invokers: InvokerFactory,
private val alarmDao: AlarmDao, private val alarmDao: AlarmDao,
@ -94,7 +94,7 @@ class GoogleTaskSynchronizer @Inject constructor(
firebase.reportException(e) firebase.reportException(e)
} finally { } finally {
caldavDao.update(account) caldavDao.update(account)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
Timber.d("%s: end sync", account) Timber.d("%s: end sync", account)
} }
} }
@ -182,7 +182,7 @@ class GoogleTaskSynchronizer @Inject constructor(
private suspend fun pushLocalChanges(account: CaldavAccount, gtasksInvoker: GtasksInvoker): Long? { private suspend fun pushLocalChanges(account: CaldavAccount, gtasksInvoker: GtasksInvoker): Long? {
val tasks = taskDao.getGoogleTasksToPush(account.uuid!!) val tasks = taskDao.getGoogleTasksToPush(account.uuid!!)
for (task in tasks) { for (task in tasks) {
val staleTaskId = pushTask(task, gtasksInvoker) val staleTaskId = pushTask(task, account.uuid!!, gtasksInvoker)
if (staleTaskId != null) { if (staleTaskId != null) {
return staleTaskId return staleTaskId
} }
@ -191,8 +191,8 @@ class GoogleTaskSynchronizer @Inject constructor(
} }
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun pushTask(task: org.tasks.data.entity.Task, gtasksInvoker: GtasksInvoker): Long? { private suspend fun pushTask(task: org.tasks.data.entity.Task, account: String, gtasksInvoker: GtasksInvoker): Long? {
for (deleted in googleTaskDao.getDeletedByTaskId(task.id)) { for (deleted in googleTaskDao.getDeletedByTaskId(task.id, account)) {
deleted.remoteId?.let { deleted.remoteId?.let {
try { try {
gtasksInvoker.deleteGtask(deleted.calendar, it) gtasksInvoker.deleteGtask(deleted.calendar, it)

@ -11,10 +11,13 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import org.tasks.LocalBroadcastManager
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.billing.BillingClient import org.tasks.billing.BillingClient
import org.tasks.billing.BillingClientImpl import org.tasks.billing.BillingClientImpl
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
import org.tasks.caldav.FileStorage
import org.tasks.caldav.VtodoCache
import org.tasks.compose.drawer.DrawerConfiguration import org.tasks.compose.drawer.DrawerConfiguration
import org.tasks.data.dao.AlarmDao import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.Astrid2ContentProviderDao import org.tasks.data.dao.Astrid2ContentProviderDao
@ -37,9 +40,13 @@ import org.tasks.jobs.WorkManager
import org.tasks.kmp.createDataStore import org.tasks.kmp.createDataStore
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.preferences.TasksPreferences import org.tasks.preferences.TasksPreferences
import org.tasks.security.AndroidKeyStoreEncryption
import org.tasks.security.KeyStoreEncryption
import java.util.Locale import java.util.Locale
import javax.inject.Singleton import javax.inject.Singleton
import org.tasks.broadcast.RefreshBroadcaster
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class ApplicationModule { class ApplicationModule {
@ -167,4 +174,22 @@ class ApplicationModule {
taskDao = taskDao, taskDao = taskDao,
tasksPreferences = tasksPreferences, tasksPreferences = tasksPreferences,
) )
@Provides
@Singleton
fun providesFileStorage(@ApplicationContext context: Context) =
FileStorage(context.filesDir.absolutePath)
@Provides
@Singleton
fun providesVtodoCache(caldavDao: CaldavDao, fileStorage: FileStorage) =
VtodoCache(caldavDao, fileStorage)
@Provides
@Singleton
fun providesKeyStoreEncryption(): KeyStoreEncryption = AndroidKeyStoreEncryption()
@Provides
fun providesBroadcastRefresh(localBroadcastManager: LocalBroadcastManager): RefreshBroadcaster =
localBroadcastManager
} }

@ -5,7 +5,7 @@ import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.data.dao.TaskDao import org.tasks.data.dao.TaskDao
import org.tasks.date.DateTimeUtils import org.tasks.date.DateTimeUtils
@ -16,13 +16,13 @@ class RefreshWork @AssistedInject constructor(
@Assisted context: Context, @Assisted context: Context,
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
firebase: Firebase, firebase: Firebase,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val workManager: WorkManager, private val workManager: WorkManager,
private val taskDao: TaskDao, private val taskDao: TaskDao,
) : RepeatingWorker(context, workerParams, firebase) { ) : RepeatingWorker(context, workerParams, firebase) {
override suspend fun run(): Result { override suspend fun run(): Result {
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
return Result.success() return Result.success()
} }

@ -6,7 +6,7 @@ import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.data.dao.LocationDao import org.tasks.data.dao.LocationDao
import org.tasks.data.entity.Place import org.tasks.data.entity.Place
@ -20,7 +20,7 @@ class ReverseGeocodeWork @AssistedInject constructor(
@Assisted context: Context, @Assisted context: Context,
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
firebase: Firebase, firebase: Firebase,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val geocoder: Geocoder, private val geocoder: Geocoder,
private val locationDao: LocationDao private val locationDao: LocationDao
) : BaseWorker(context, workerParams, firebase) { ) : BaseWorker(context, workerParams, firebase) {
@ -51,7 +51,7 @@ class ReverseGeocodeWork @AssistedInject constructor(
url = result.url, url = result.url,
) )
) )
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
Timber.d("found $result") Timber.d("found $result")
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {

@ -17,7 +17,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
@ -44,7 +44,7 @@ class SyncWork @AssistedInject constructor(
@Assisted context: Context, @Assisted context: Context,
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
firebase: Firebase, firebase: Firebase,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val preferences: Preferences, private val preferences: Preferences,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val caldavSynchronizer: Lazy<CaldavSynchronizer>, private val caldavSynchronizer: Lazy<CaldavSynchronizer>,
@ -74,7 +74,7 @@ class SyncWork @AssistedInject constructor(
} }
preferences.setBoolean(syncStatus, true) preferences.setBoolean(syncStatus, true)
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
try { try {
doSync() doSync()
preferences.lastSync = currentTimeMillis() preferences.lastSync = currentTimeMillis()
@ -82,7 +82,7 @@ class SyncWork @AssistedInject constructor(
firebase.reportException(e) firebase.reportException(e)
} finally { } finally {
preferences.setBoolean(syncStatus, false) preferences.setBoolean(syncStatus, false)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
return Result.success() return Result.success()
} }

@ -13,7 +13,7 @@ import com.todoroo.astrid.utility.Constants
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.data.dao.LocationDao import org.tasks.data.dao.LocationDao
import org.tasks.data.dao.NotificationDao import org.tasks.data.dao.NotificationDao
@ -45,7 +45,7 @@ class NotificationManager @Inject constructor(
private val notificationDao: NotificationDao, private val notificationDao: NotificationDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val locationDao: LocationDao, private val locationDao: LocationDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val notificationManager: ThrottledNotificationManager, private val notificationManager: ThrottledNotificationManager,
private val markdownProvider: MarkdownProvider, private val markdownProvider: MarkdownProvider,
private val permissionChecker: PermissionChecker, private val permissionChecker: PermissionChecker,
@ -176,7 +176,7 @@ class NotificationManager @Inject constructor(
useGroupKey = false, useGroupKey = false,
) )
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")

@ -7,7 +7,7 @@ import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.dmfs.tasks.contract.TaskContract import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.Tasks import org.dmfs.tasks.contract.TaskContract.Tasks
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.analytics.Constants import org.tasks.analytics.Constants
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
@ -36,7 +36,7 @@ class OpenTasksSynchronizer @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val firebase: Firebase, private val firebase: Firebase,
private val iCalendar: iCalendar, private val iCalendar: iCalendar,
@ -111,7 +111,7 @@ class OpenTasksSynchronizer @Inject constructor(
if (local.id == NO_ID) { if (local.id == NO_ID) {
caldavDao.insert(local) caldavDao.insert(local)
Timber.d("Created calendar: $local") Timber.d("Created calendar: $local")
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} else if ( } else if (
local.name != remote.name || local.name != remote.name ||
local.color != remote.color || local.color != remote.color ||
@ -122,7 +122,7 @@ class OpenTasksSynchronizer @Inject constructor(
local.access = remote.access local.access = remote.access
caldavDao.update(local) caldavDao.update(local)
Timber.d("Updated calendar: $local") Timber.d("Updated calendar: $local")
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
return local return local
} }
@ -184,7 +184,7 @@ class OpenTasksSynchronizer @Inject constructor(
caldavDao.update(calendar) caldavDao.update(calendar)
Timber.d("Updating parents for ${calendar.uuid}") Timber.d("Updating parents for ${calendar.uuid}")
caldavDao.updateParents(calendar.uuid!!) caldavDao.updateParents(calendar.uuid!!)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun removeDeleted(calendar: String, uids: List<String>) { private suspend fun removeDeleted(calendar: String, uids: List<String>) {
@ -202,7 +202,7 @@ class OpenTasksSynchronizer @Inject constructor(
private suspend fun setError(account: CaldavAccount, message: String?) { private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message account.error = message
caldavDao.update(account) caldavDao.update(account)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
if (!message.isNullOrBlank()) { if (!message.isNullOrBlank()) {
Timber.e(message) Timber.e(message)
} }

@ -6,12 +6,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.RingtoneManager
import android.net.Uri import android.net.Uri
import android.os.Binder import android.os.Binder
import androidx.compose.material3.DisplayMode import androidx.compose.material3.DisplayMode
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@ -30,8 +28,6 @@ import org.tasks.extensions.Context.getResourceUri
import org.tasks.kmp.org.tasks.themes.ColorProvider.BLUE_500 import org.tasks.kmp.org.tasks.themes.ColorProvider.BLUE_500
import org.tasks.themes.ThemeBase import org.tasks.themes.ThemeBase
import org.tasks.time.DateTime import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import org.tasks.time.ONE_WEEK
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.net.URI import java.net.URI
@ -382,7 +378,7 @@ class Preferences @JvmOverloads constructor(
val backupDirectory: Uri? val backupDirectory: Uri?
get() = getDirectory(R.string.p_backup_dir, "backups") get() = getDirectory(R.string.p_backup_dir, "backups")
val externalStorage: Uri val appPrivateStorage: Uri
get() = root.uri get() = root.uri
val attachmentsDirectory: Uri? val attachmentsDirectory: Uri?
@ -416,13 +412,12 @@ class Preferences @JvmOverloads constructor(
?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) } ?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) }
private val root: DocumentFile private val root: DocumentFile
get() = DocumentFile.fromFile(context.getExternalFilesDir(null)!!) get() = DocumentFile.fromFile(context.getExternalFilesDir(null) ?: context.filesDir)
private fun getDefaultFileLocation(type: String): File? { private fun getDefaultFileLocation(type: String): File? {
val externalFilesDir = context.getExternalFilesDir(null) ?: return null val baseDir = context.getExternalFilesDir(null) ?: context.filesDir
val path = String.format("%s/%s", externalFilesDir.absolutePath, type) val path = File(baseDir, type)
val file = File(path) return if (path.isDirectory || path.mkdirs()) path else null
return if (file.isDirectory || file.mkdirs()) file else null
} }
private fun hasWritePermission(context: Context, uri: Uri): Boolean = private fun hasWritePermission(context: Context, uri: Uri): Boolean =

@ -2,6 +2,7 @@ package org.tasks.preferences
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -51,8 +52,16 @@ class PreferencesViewModel @Inject constructor(
get() = isStale(lastDriveBackup.value) && isStale(lastAndroidBackup.value) get() = isStale(lastDriveBackup.value) && isStale(lastAndroidBackup.value)
val usingPrivateStorage: Boolean val usingPrivateStorage: Boolean
get() = preferences.backupDirectory.let { get() = preferences.backupDirectory.let { backupDir ->
it == null || it.toString().startsWith(preferences.externalStorage.toString()) val backupDirStr = backupDir?.toString() ?: return true
context
.getExternalFilesDir(null)
?.let {
if (backupDirStr.startsWith(Uri.fromFile(it).toString())) {
return true
}
}
return backupDirStr.startsWith(Uri.fromFile(context.filesDir).toString())
} }
val driveAccount: String? val driveAccount: String?

@ -257,7 +257,7 @@ class Backups : InjectingPreferenceFragment() {
pref.summary = """ pref.summary = """
$location $location
${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.externalStorage))} ${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.appPrivateStorage))}
""".trimIndent() """.trimIndent()
} else { } else {
pref.icon = null pref.icon = null

@ -4,8 +4,8 @@ import android.os.Bundle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult
import org.tasks.injection.InjectingPreferenceFragment import org.tasks.injection.InjectingPreferenceFragment
@ -16,14 +16,14 @@ import javax.inject.Inject
class DashClock : InjectingPreferenceFragment() { class DashClock : InjectingPreferenceFragment() {
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider @Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
private val listPickerLauncher = registerForFilterPickerResult { private val listPickerLauncher = registerForFilterPickerResult {
defaultFilterProvider.dashclockFilter = it defaultFilterProvider.dashclockFilter = it
lifecycleScope.launch { lifecycleScope.launch {
refreshPreferences() refreshPreferences()
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
override fun getPreferenceXml() = R.xml.preferences_dashclock override fun getPreferenceXml() = R.xml.preferences_dashclock

@ -18,10 +18,10 @@ import com.google.android.material.color.DynamicColors
import com.todoroo.andlib.utility.AndroidUtilities.atLeastTiramisu import com.todoroo.andlib.utility.AndroidUtilities.atLeastTiramisu
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
import org.tasks.billing.PurchaseActivity import org.tasks.billing.PurchaseActivity
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult
import org.tasks.dialogs.ColorPalettePicker import org.tasks.dialogs.ColorPalettePicker
@ -48,7 +48,7 @@ class LookAndFeel : InjectingPreferenceFragment() {
@Inject lateinit var themeBase: ThemeBase @Inject lateinit var themeBase: ThemeBase
@Inject lateinit var themeColor: ThemeColor @Inject lateinit var themeColor: ThemeColor
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider @Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var inventory: Inventory @Inject lateinit var inventory: Inventory
@Inject lateinit var locale: Locale @Inject lateinit var locale: Locale
@ -56,7 +56,7 @@ class LookAndFeel : InjectingPreferenceFragment() {
private val listPickerLauncher = registerForFilterPickerResult { private val listPickerLauncher = registerForFilterPickerResult {
defaultFilterProvider.setDefaultOpenFilter(it) defaultFilterProvider.setDefaultOpenFilter(it)
findPreference(R.string.p_default_open_filter).summary = it.title findPreference(R.string.p_default_open_filter).summary = it.title
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
override fun getPreferenceXml() = R.xml.preferences_look_and_feel override fun getPreferenceXml() = R.xml.preferences_look_and_feel

@ -17,8 +17,8 @@ import com.todoroo.andlib.utility.AndroidUtilities
import com.todoroo.astrid.voice.VoiceOutputAssistant import com.todoroo.astrid.voice.VoiceOutputAssistant
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult
import org.tasks.dialogs.MyTimePickerDialog.Companion.newTimePicker import org.tasks.dialogs.MyTimePickerDialog.Companion.newTimePicker
@ -39,13 +39,13 @@ class Notifications : InjectingPreferenceFragment() {
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider @Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var voiceOutputAssistant: VoiceOutputAssistant @Inject lateinit var voiceOutputAssistant: VoiceOutputAssistant
private val listPickerLauncher = registerForFilterPickerResult { private val listPickerLauncher = registerForFilterPickerResult {
defaultFilterProvider.setBadgeFilter(it) defaultFilterProvider.setBadgeFilter(it)
findPreference(R.string.p_badge_list).summary = it.title findPreference(R.string.p_badge_list).summary = it.title
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
override fun getPreferenceXml() = R.xml.preferences_notifications override fun getPreferenceXml() = R.xml.preferences_notifications

@ -1,10 +0,0 @@
package org.tasks.reminders;
public class Random {
private static final java.util.Random random = new java.util.Random();
public float nextFloat() {
return random.nextFloat();
}
}

@ -0,0 +1,14 @@
package org.tasks.reminders
import java.util.Random
open class Random {
open fun nextFloat(seed: Long): Float {
random.setSeed(seed)
return random.nextFloat()
}
companion object {
private val random = Random()
}
}

@ -1,97 +0,0 @@
package org.tasks.security
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.tasks.Strings.isNullOrEmpty
import timber.log.Timber
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.security.SecureRandom
import java.util.*
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KeyStoreEncryption @Inject constructor() {
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
fun encrypt(text: String): String? {
val iv = ByteArray(GCM_IV_LENGTH)
SecureRandom().nextBytes(iv)
val cipher = getCipher(Cipher.ENCRYPT_MODE, iv)
return try {
val output = cipher.doFinal(text.toByteArray(ENCODING))
val result = ByteArray(iv.size + output.size)
System.arraycopy(iv, 0, result, 0, iv.size)
System.arraycopy(output, 0, result, iv.size, output.size)
Base64.encodeToString(result, Base64.DEFAULT)
} catch (e: IllegalBlockSizeException) {
Timber.e(e)
null
} catch (e: BadPaddingException) {
Timber.e(e)
null
}
}
fun decrypt(text: String?): String? {
if (isNullOrEmpty(text)) {
return null
}
val decoded = Base64.decode(text, Base64.DEFAULT)
val iv = Arrays.copyOfRange(decoded, 0, GCM_IV_LENGTH)
val cipher = getCipher(Cipher.DECRYPT_MODE, iv)
return try {
val decrypted = cipher.doFinal(decoded, GCM_IV_LENGTH, decoded.size - GCM_IV_LENGTH)
String(decrypted, ENCODING)
} catch (e: IllegalBlockSizeException) {
Timber.e(e)
""
} catch (e: BadPaddingException) {
Timber.e(e)
""
}
}
private fun getCipher(cipherMode: Int, iv: ByteArray): Cipher {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(cipherMode, secretKey, GCMParameterSpec(GCM_TAG_LENGTH * java.lang.Byte.SIZE, iv))
return cipher
}
private val secretKey: SecretKey
get() {
val entry: KeyStore.Entry? = keyStore.getEntry(ALIAS, null)
return (entry as KeyStore.SecretKeyEntry?)?.secretKey ?: generateNewKey()
}
@SuppressLint("TrulyRandom")
private fun generateNewKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
keyGenerator.init(
KeyGenParameterSpec.Builder(
ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setRandomizedEncryptionRequired(false)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build())
return keyGenerator.generateKey()
}
init {
keyStore.load(null)
}
companion object {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val ALIAS = "passwords"
private val ENCODING = StandardCharsets.UTF_8
private const val GCM_IV_LENGTH = 12
private const val GCM_TAG_LENGTH = 16
}
}

@ -5,7 +5,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.R import org.tasks.R
import org.tasks.data.OpenTaskDao import org.tasks.data.OpenTaskDao
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
@ -34,7 +34,7 @@ class SyncAdapters @Inject constructor(
private val googleTaskDao: GoogleTaskDao, private val googleTaskDao: GoogleTaskDao,
private val openTaskDao: OpenTaskDao, private val openTaskDao: OpenTaskDao,
private val preferences: Preferences, private val preferences: Preferences,
private val localBroadcastManager: LocalBroadcastManager private val refreshBroadcaster: RefreshBroadcaster
) { ) {
private val scope = CoroutineScope(newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob()) private val scope = CoroutineScope(newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob())
private val sync = Debouncer(TAG_SYNC) { workManager.sync(it) } private val sync = Debouncer(TAG_SYNC) { workManager.sync(it) }
@ -42,7 +42,7 @@ class SyncAdapters @Inject constructor(
val currentState = preferences.getBoolean(R.string.p_sync_ongoing_android, false) val currentState = preferences.getBoolean(R.string.p_sync_ongoing_android, false)
if (currentState != newState && isOpenTaskSyncEnabled()) { if (currentState != newState && isOpenTaskSyncEnabled()) {
preferences.setBoolean(R.string.p_sync_ongoing_android, newState) preferences.setBoolean(R.string.p_sync_ongoing_android, newState)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
} }

@ -25,14 +25,15 @@ object MicrosoftConverter {
fun Task.applySubtask( fun Task.applySubtask(
parent: Long, parent: Long,
parentCompletionDate: Long,
checklistItem: Tasks.Task.ChecklistItem, checklistItem: Tasks.Task.ChecklistItem,
) { ) {
this.parent = parent this.parent = parent
title = checklistItem.displayName title = checklistItem.displayName
completionDate = if (checklistItem.isChecked) { completionDate = if (checklistItem.isChecked) {
checklistItem.checkedDateTime?.parseDateTime() ?: System.currentTimeMillis() checklistItem.checkedDateTime.parseDateTime()
} else { } else {
0L parentCompletionDate
} }
creationDate = checklistItem.createdDateTime.parseDateTime() creationDate = checklistItem.createdDateTime.parseDateTime()
} }
@ -120,7 +121,7 @@ object MicrosoftConverter {
} else { } else {
Tasks.Task.Status.notStarted Tasks.Task.Status.notStarted
}, },
categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() }, categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() } ?: emptyList(),
dueDateTime = if (hasDueDate()) { dueDateTime = if (hasDueDate()) {
Tasks.Task.DateTime( Tasks.Task.DateTime(
dateTime = DateTime(dueDate).startOfDay().toString(DATE_TIME_FORMAT), dateTime = DateTime(dueDate).startOfDay().toString(DATE_TIME_FORMAT),

@ -11,15 +11,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.tasks.LocalBroadcastManager
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.VtodoCache import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.TagDao import org.tasks.data.dao.TagDao
import org.tasks.data.dao.TagDataDao import org.tasks.data.dao.TagDataDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.ERROR_UNAUTHORIZED
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE
@ -52,7 +51,7 @@ class MicrosoftSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val taskDao: TaskDao, private val taskDao: TaskDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val firebase: Firebase, private val firebase: Firebase,
private val taskCreator: TaskCreator, private val taskCreator: TaskCreator,
@ -130,7 +129,7 @@ class MicrosoftSynchronizer @Inject constructor(
} else if (local.name != remoteName || local.access != access) { } else if (local.name != remoteName || local.access != access) {
remote.applyTo(local) remote.applyTo(local)
caldavDao.update(local) caldavDao.update(local)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
} }
if (local.ctag?.isNotBlank() == true) { if (local.ctag?.isNotBlank() == true) {
deltaSync(account, local, remote, microsoft) deltaSync(account, local, remote, microsoft)
@ -275,7 +274,7 @@ class MicrosoftSynchronizer @Inject constructor(
} }
Timber.d("UPDATE $list") Timber.d("UPDATE $list")
caldavDao.update(list) caldavDao.update(list)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun getTaskLists( private suspend fun getTaskLists(
@ -368,7 +367,7 @@ class MicrosoftSynchronizer @Inject constructor(
} }
Timber.d("UPDATE $list") Timber.d("UPDATE $list")
caldavDao.update(list) caldavDao.update(list)
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private suspend fun updateTask(list: CaldavCalendar, remote: Tasks.Task) { private suspend fun updateTask(list: CaldavCalendar, remote: Tasks.Task) {
@ -403,6 +402,7 @@ class MicrosoftSynchronizer @Inject constructor(
list = list, list = list,
parentId = task.id, parentId = task.id,
parentRemoteId = caldavTask.remoteId!!, parentRemoteId = caldavTask.remoteId!!,
parentCompletionDate = task.completionDate,
checklistItems = it, checklistItems = it,
) )
} }
@ -421,6 +421,7 @@ class MicrosoftSynchronizer @Inject constructor(
list: CaldavCalendar, list: CaldavCalendar,
parentId: Long, parentId: Long,
parentRemoteId: String, parentRemoteId: String,
parentCompletionDate: Long,
checklistItems: List<Tasks.Task.ChecklistItem>, checklistItems: List<Tasks.Task.ChecklistItem>,
) { ) {
val existingSubtasks: List<CaldavTask> = taskDao.getChildren(parentId).let { caldavDao.getTasks(it) } val existingSubtasks: List<CaldavTask> = taskDao.getChildren(parentId).let { caldavDao.getTasks(it) }
@ -451,6 +452,7 @@ class MicrosoftSynchronizer @Inject constructor(
} else { } else {
task.applySubtask( task.applySubtask(
parent = parentId, parent = parentId,
parentCompletionDate = parentCompletionDate,
checklistItem = item, checklistItem = item,
) )
} }
@ -488,7 +490,7 @@ class MicrosoftSynchronizer @Inject constructor(
private suspend fun setError(account: CaldavAccount, message: String?) { private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message account.error = message
caldavDao.update(account) caldavDao.update(account)
localBroadcastManager.broadcastRefreshList() refreshBroadcaster.broadcastRefresh()
if (!isNullOrEmpty(message)) { if (!isNullOrEmpty(message)) {
Timber.e(message) Timber.e(message)
} }

@ -1,3 +1,5 @@
@file:OptIn(ExperimentalSerializationApi::class)
package org.tasks.sync.microsoft package org.tasks.sync.microsoft
import kotlinx.serialization.EncodeDefault import kotlinx.serialization.EncodeDefault
@ -17,18 +19,18 @@ data class Tasks(
@SerialName("@odata.etag") val etag: String? = null, @SerialName("@odata.etag") val etag: String? = null,
val id: String? = null, val id: String? = null,
@Redacted val title: String? = null, @Redacted val title: String? = null,
val body: Body? = null, @EncodeDefault val body: Body? = null,
@EncodeDefault val importance: Importance = Importance.low, @EncodeDefault val importance: Importance = Importance.low,
@EncodeDefault val status: Status = Status.notStarted, @EncodeDefault val status: Status = Status.notStarted,
val categories: List<String>? = null, val categories: List<String>? = null,
val isReminderOn: Boolean = false, val isReminderOn: Boolean = false,
val createdDateTime: String? = null, val createdDateTime: String? = null,
val lastModifiedDateTime: String? = null, val lastModifiedDateTime: String? = null,
val completedDateTime: DateTime? = null, @EncodeDefault val completedDateTime: DateTime? = null,
val dueDateTime: DateTime? = null, @EncodeDefault val dueDateTime: DateTime? = null,
val linkedResources: List<LinkedResource>? = null, val linkedResources: List<LinkedResource>? = null,
val recurrence: Recurrence? = null, @EncodeDefault val recurrence: Recurrence? = null,
val reminderDateTime: DateTime? = null, @EncodeDefault val reminderDateTime: DateTime? = null,
val checklistItems: List<ChecklistItem>? = null, val checklistItems: List<ChecklistItem>? = null,
@SerialName("@removed") val removed: Removed? = null, @SerialName("@removed") val removed: Removed? = null,
) { ) {
@ -106,7 +108,7 @@ data class Tasks(
val displayName: String, val displayName: String,
val createdDateTime: String? = null, val createdDateTime: String? = null,
val isChecked: Boolean, val isChecked: Boolean,
val checkedDateTime: String? = null, @EncodeDefault val checkedDateTime: String? = null,
) )
enum class Importance { enum class Importance {

@ -6,7 +6,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.tasks.LocalBroadcastManager import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.throttleLatest import org.tasks.compose.throttleLatest
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.TagDataDao import org.tasks.data.dao.TagDataDao
@ -23,7 +23,7 @@ import javax.inject.Singleton
class ChipListCache @Inject internal constructor( class ChipListCache @Inject internal constructor(
caldavDao: CaldavDao, caldavDao: CaldavDao,
tagDataDao: TagDataDao, tagDataDao: TagDataDao,
private val localBroadcastManager: LocalBroadcastManager, private val refreshBroadcaster: RefreshBroadcaster,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lists: MutableMap<String?, CaldavFilter> = HashMap() private val lists: MutableMap<String?, CaldavFilter> = HashMap()
@ -42,7 +42,7 @@ class ChipListCache @Inject internal constructor(
lists.clear() lists.clear()
it.associateByTo(lists) { filter -> filter.uuid } it.associateByTo(lists) { filter -> filter.uuid }
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
private fun updateTags(updated: List<TagData>) { private fun updateTags(updated: List<TagData>) {
@ -51,7 +51,7 @@ class ChipListCache @Inject internal constructor(
for (update in updated) { for (update in updated) {
tagDatas[update.remoteId] = TagFilter(update) tagDatas[update.remoteId] = TagFilter(update)
} }
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
} }
fun getCaldavList(caldav: String?): CaldavFilter? = lists[caldav] fun getCaldavList(caldav: String?): CaldavFilter? = lists[caldav]

@ -7,9 +7,14 @@ import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.R import org.tasks.R
import org.tasks.compose.throttleLatest
import org.tasks.injection.ApplicationScope import org.tasks.injection.ApplicationScope
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -19,29 +24,44 @@ class AppWidgetManager @Inject constructor(
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val appWidgetManager: AppWidgetManager? = AppWidgetManager.getInstance(context) private val appWidgetManager: AppWidgetManager? = AppWidgetManager.getInstance(context)
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
init {
updateChannel
.consumeAsFlow()
.throttleLatest(1000)
.onEach {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
notifyAppWidgetViewDataChanged(appWidgetIds)
}
.launchIn(scope)
}
val widgetIds: IntArray val widgetIds: IntArray
get() = appWidgetManager get() = appWidgetManager
?.getAppWidgetIds(ComponentName(context, TasksWidget::class.java)) ?.getAppWidgetIds(ComponentName(context, TasksWidget::class.java))
?: intArrayOf() ?: intArrayOf()
fun reconfigureWidgets(vararg appWidgetIds: Int) = scope.launch(Dispatchers.IO) { fun reconfigureWidgets(vararg appWidgetIds: Int) = scope.launch {
Timber.d("reconfigureWidgets(${appWidgetIds.joinToString()})") Timber.d("reconfigureWidgets(${appWidgetIds.joinToString()})")
val ids = appWidgetIds.takeIf { it.isNotEmpty() } ?: widgetIds
val intent = Intent(context, TasksWidget::class.java) val intent = Intent(context, TasksWidget::class.java)
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
intent.putExtra( .apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE }
AppWidgetManager.EXTRA_APPWIDGET_IDS,
appWidgetIds.takeIf { it.isNotEmpty() } ?: widgetIds)
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
fun updateWidgets() = scope.launch(Dispatchers.IO) { fun updateWidgets() {
val appWidgetIds = widgetIds updateChannel.trySend(Unit)
Timber.d("updateWidgets: ${appWidgetIds.joinToString()}")
withContext(Dispatchers.Main) {
appWidgetManager?.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.list_view)
}
} }
fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null
private suspend fun notifyAppWidgetViewDataChanged(appWidgetIds: IntArray) = withContext(Dispatchers.Main) {
appWidgetManager?.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.list_view)
}
} }

@ -5,10 +5,10 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.net.toUri
import com.todoroo.andlib.utility.AndroidUtilities.atLeastS import com.todoroo.andlib.utility.AndroidUtilities.atLeastS
import com.todoroo.astrid.activity.MainActivity.Companion.FINISH_AFFINITY import com.todoroo.astrid.activity.MainActivity.Companion.FINISH_AFFINITY
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -35,13 +35,11 @@ class TasksWidget : AppWidgetProvider() {
@Inject @ApplicationContext lateinit var context: Context @Inject @ApplicationContext lateinit var context: Context
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
appWidgetIds.forEach { appWidgetId -> Timber.d("onUpdate appWidgetIds=${appWidgetIds.joinToString { it.toString() }}")
appWidgetIds.forEach { id ->
try { try {
val options = appWidgetManager.getAppWidgetOptions(appWidgetId) val options = appWidgetManager.getAppWidgetOptions(id)
appWidgetManager.updateAppWidget( appWidgetManager.updateAppWidget(id, createWidget(context, id, options))
appWidgetId,
createWidget(context, appWidgetId, options)
)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} }
@ -54,6 +52,7 @@ class TasksWidget : AppWidgetProvider() {
appWidgetId: Int, appWidgetId: Int,
newOptions: Bundle newOptions: Bundle
) { ) {
Timber.d("onAppWidgetOptionsChanged appWidgetId=$appWidgetId")
appWidgetManager.updateAppWidget( appWidgetManager.updateAppWidget(
appWidgetId, appWidgetId,
createWidget(context, appWidgetId, newOptions) createWidget(context, appWidgetId, newOptions)
@ -69,6 +68,8 @@ class TasksWidget : AppWidgetProvider() {
val filter = runBlocking { val filter = runBlocking {
defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId) defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId)
} }
Timber.d("createWidget id=$id filter=$filter")
return RemoteViews(context.packageName, R.layout.scrollable_widget).apply { return RemoteViews(context.packageName, R.layout.scrollable_widget).apply {
if (settings.showHeader) { if (settings.showHeader) {
setViewVisibility(R.id.widget_header, View.VISIBLE) setViewVisibility(R.id.widget_header, View.VISIBLE)
@ -87,7 +88,7 @@ class TasksWidget : AppWidgetProvider() {
opacity = widgetPreferences.footerOpacity, opacity = widgetPreferences.footerOpacity,
) )
setOnClickPendingIntent(R.id.empty_view, getOpenListIntent(context, filter, id)) setOnClickPendingIntent(R.id.empty_view, getOpenListIntent(context, filter, id))
val cacheBuster = Uri.parse("tasks://widget/" + currentTimeMillis()) val cacheBuster = "tasks://widget/${currentTimeMillis()}".toUri()
setRemoteAdapter( setRemoteAdapter(
R.id.list_view, R.id.list_view,
Intent(context, TasksWidgetAdapter::class.java) Intent(context, TasksWidgetAdapter::class.java)

@ -13,6 +13,7 @@ import org.tasks.markdown.MarkdownProvider
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter import org.tasks.tasklist.HeaderFormatter
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -32,6 +33,7 @@ class TasksWidgetAdapter : RemoteViewsService() {
val filter = runBlocking { val filter = runBlocking {
defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId) defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId)
} }
Timber.d("onGetViewFactory $filter")
return TasksWidgetViewFactory( return TasksWidgetViewFactory(
subtasksHelper, subtasksHelper,
widgetPreferences, widgetPreferences,

@ -51,7 +51,7 @@ internal class TasksWidgetViewFactory(
private val markdown: Markdown, private val markdown: Markdown,
private val headerFormatter: HeaderFormatter, private val headerFormatter: HeaderFormatter,
) : RemoteViewsFactory { ) : RemoteViewsFactory {
private val taskLimit = if (atLeastAndroid16()) 25 + 1 else Int.MAX_VALUE private val taskLimit = if (atLeastAndroid16()) 50 + 1 else Int.MAX_VALUE
private val indentPadding = (20 * context.resources.displayMetrics.density).toInt() private val indentPadding = (20 * context.resources.displayMetrics.density).toInt()
private val settings = widgetPreferences.getWidgetListSettings() private val settings = widgetPreferences.getWidgetListSettings()
private val hPad = context.resources.getDimension(R.dimen.widget_padding).toInt() private val hPad = context.resources.getDimension(R.dimen.widget_padding).toInt()
@ -66,9 +66,12 @@ internal class TasksWidgetViewFactory(
chipProvider.isDark = settings.isDark chipProvider.isDark = settings.isDark
} }
override fun onCreate() {} override fun onCreate() {
Timber.d("onCreate widgetId:$widgetId filter:$filter")
}
override fun onDataSetChanged() { override fun onDataSetChanged() {
Timber.v("onDataSetChanged $filter")
runBlocking { runBlocking {
val collapsed = widgetPreferences.collapsed val collapsed = widgetPreferences.collapsed
tasks = SectionedDataSource( tasks = SectionedDataSource(
@ -87,7 +90,9 @@ internal class TasksWidgetViewFactory(
} }
} }
override fun onDestroy() {} override fun onDestroy() {
Timber.d("onDestroy widgetId:$widgetId")
}
override fun getCount() = tasks.size.coerceAtMost(taskLimit) override fun getCount() = tasks.size.coerceAtMost(taskLimit)
@ -217,7 +222,7 @@ internal class TasksWidgetViewFactory(
R.id.widget_row, R.id.widget_row,
Intent(WidgetClickActivity.EDIT_TASK) Intent(WidgetClickActivity.EDIT_TASK)
.putExtra(WidgetClickActivity.EXTRA_FILTER, filter) .putExtra(WidgetClickActivity.EXTRA_FILTER, filter)
.putExtra(WidgetClickActivity.EXTRA_TASK, task) .putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
) )
if (settings.showCheckboxes) { if (settings.showCheckboxes) {
setViewPadding( setViewPadding(
@ -232,7 +237,8 @@ internal class TasksWidgetViewFactory(
setOnClickFillInIntent( setOnClickFillInIntent(
R.id.widget_complete_box, R.id.widget_complete_box,
Intent(WidgetClickActivity.COMPLETE_TASK) Intent(WidgetClickActivity.COMPLETE_TASK)
.putExtra(WidgetClickActivity.EXTRA_TASK, task) .putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
.putExtra(WidgetClickActivity.EXTRA_COMPLETED, !task.isCompleted)
) )
} else { } else {
setViewPadding(R.id.widget_complete_box, hPad, 0, 0, 0) setViewPadding(R.id.widget_complete_box, hPad, 0, 0, 0)
@ -250,7 +256,7 @@ internal class TasksWidgetViewFactory(
setOnClickFillInIntent( setOnClickFillInIntent(
R.id.chip, R.id.chip,
Intent(WidgetClickActivity.TOGGLE_SUBTASKS) Intent(WidgetClickActivity.TOGGLE_SUBTASKS)
.putExtra(WidgetClickActivity.EXTRA_TASK, task) .putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
.putExtra( .putExtra(
WidgetClickActivity.EXTRA_COLLAPSED, WidgetClickActivity.EXTRA_COLLAPSED,
!taskContainer.isCollapsed !taskContainer.isCollapsed
@ -332,11 +338,10 @@ internal class TasksWidgetViewFactory(
setOnClickFillInIntent( setOnClickFillInIntent(
dueDateRes, dueDateRes,
Intent(WidgetClickActivity.RESCHEDULE_TASK) Intent(WidgetClickActivity.RESCHEDULE_TASK)
.putExtra(WidgetClickActivity.EXTRA_TASK, task.task) .putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
) )
} else { } else {
setViewVisibility(dueDateRes, View.GONE) setViewVisibility(dueDateRes, View.GONE)
} }
} }
} }

@ -3,7 +3,6 @@ package org.tasks.widget
import android.content.Context import android.content.Context
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import com.mikepenz.iconics.IconicsDrawable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.tasks.BuildConfig import org.tasks.BuildConfig
@ -19,7 +18,6 @@ import org.tasks.filters.Filter
import org.tasks.filters.PlaceFilter import org.tasks.filters.PlaceFilter
import org.tasks.filters.TagFilter import org.tasks.filters.TagFilter
import org.tasks.filters.getIcon import org.tasks.filters.getIcon
import org.tasks.icons.OutlinedGoogleMaterial
import org.tasks.kmp.org.tasks.time.getRelativeDateTime import org.tasks.kmp.org.tasks.time.getRelativeDateTime
import org.tasks.kmp.org.tasks.time.getTimeString import org.tasks.kmp.org.tasks.time.getTimeString
import org.tasks.time.startOfDay import org.tasks.time.startOfDay
@ -115,14 +113,16 @@ class WidgetChipProvider @Inject constructor(
setTextViewText(R.id.chip_text, filter.title) setTextViewText(R.id.chip_text, filter.title)
filter filter
.getIcon(inventory) .getIcon(inventory)
?.let { ?.let { iconName ->
try { try {
OutlinedGoogleMaterial.getIcon("gmo_$it") val iconUri = WidgetIconProvider.getIconUri(
} catch (_: IllegalArgumentException) { iconName = iconName,
null )
setImageViewUri(R.id.chip_icon, iconUri)
} catch (_: Exception) {
setImageViewResource(R.id.chip_icon, defaultIcon)
} }
} }
?.let { setImageViewBitmap(R.id.chip_icon, IconicsDrawable(context, it).toBitmap()) }
?: setImageViewResource(R.id.chip_icon, defaultIcon) ?: setImageViewResource(R.id.chip_icon, defaultIcon)
} }

@ -9,10 +9,9 @@ import com.todoroo.astrid.service.TaskCompleter
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.data.entity.Task import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.dialogs.BaseDateTimePicker.OnDismissHandler import org.tasks.dialogs.BaseDateTimePicker.OnDismissHandler
import org.tasks.dialogs.DateTimePicker.Companion.newDateTimePicker import org.tasks.dialogs.DateTimePicker.Companion.newDateTimePicker
import org.tasks.filters.Filter import org.tasks.filters.Filter
@ -25,7 +24,7 @@ import javax.inject.Inject
class WidgetClickActivity : AppCompatActivity(), OnDismissHandler { class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
@Inject lateinit var taskCompleter: TaskCompleter @Inject lateinit var taskCompleter: TaskCompleter
@Inject lateinit var taskDao: TaskDao @Inject lateinit var taskDao: TaskDao
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@Inject lateinit var firebase: Firebase @Inject lateinit var firebase: Firebase
@ -34,29 +33,38 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
val intent = intent val intent = intent
val action = intent.action val action = intent.action
if (action.isNullOrEmpty()) { if (action.isNullOrEmpty()) {
finish()
return return
} }
when (action) { when (action) {
COMPLETE_TASK -> { COMPLETE_TASK -> {
val task = task val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
Timber.tag("$action task=$task") val completed = intent.getBooleanExtra(EXTRA_COMPLETED, false)
Timber.tag("$action taskId=$taskId completed=$completed")
if (taskId > 0) {
lifecycleScope.launch(NonCancellable) { lifecycleScope.launch(NonCancellable) {
taskCompleter.setComplete(task, !task.isCompleted) taskCompleter.setComplete(taskId, completed)
firebase.completeTask("widget") firebase.completeTask("widget")
} }
}
finish() finish()
} }
EDIT_TASK -> { EDIT_TASK -> {
val filter = intent.getParcelableExtra<Filter?>(EXTRA_FILTER) val filter = intent.getParcelableExtra<Filter?>(EXTRA_FILTER)
val task = task val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
Timber.tag("$action task=$task filter=$filter") Timber.tag("$action taskId=$taskId filter=$filter")
lifecycleScope.launch {
if (taskId > 0) {
val task = taskDao.fetch(taskId)
startActivity( startActivity(
TaskIntents TaskIntents
.getEditTaskIntent(this, filter, task) .getEditTaskIntent(this@WidgetClickActivity, filter, task)
.putExtra(FINISH_AFFINITY, true) .putExtra(FINISH_AFFINITY, true)
) )
}
finish() finish()
} }
}
OPEN_TASK_LIST -> { OPEN_TASK_LIST -> {
val filter = intent.getParcelableExtra<Filter?>(EXTRA_FILTER) val filter = intent.getParcelableExtra<Filter?>(EXTRA_FILTER)
Timber.tag("$action filter=$filter") Timber.tag("$action filter=$filter")
@ -68,23 +76,36 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
finish() finish()
} }
TOGGLE_SUBTASKS -> { TOGGLE_SUBTASKS -> {
val task = task val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
val collapsed = intent.getBooleanExtra(EXTRA_COLLAPSED, false) val collapsed = intent.getBooleanExtra(EXTRA_COLLAPSED, false)
Timber.d("$action collapsed=$collapsed task=$task") Timber.d("$action collapsed=$collapsed taskId=$taskId")
if (taskId > 0) {
lifecycleScope.launch(NonCancellable) { lifecycleScope.launch(NonCancellable) {
taskDao.setCollapsed(task.id, collapsed) taskDao.setCollapsed(taskId, collapsed)
}
} }
finish() finish()
} }
RESCHEDULE_TASK -> { RESCHEDULE_TASK -> {
val task = task val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
Timber.d("$action task=$task") Timber.d("$action taskId=$taskId")
val fragmentManager = supportFragmentManager val fragmentManager = supportFragmentManager
if (fragmentManager.findFragmentByTag(FRAG_TAG_DATE_TIME_PICKER) == null) { if (fragmentManager.findFragmentByTag(FRAG_TAG_DATE_TIME_PICKER) == null) {
lifecycleScope.launch {
val task = taskDao.fetch(taskId)
if (task != null) {
newDateTimePicker( newDateTimePicker(
preferences.getBoolean(R.string.p_auto_dismiss_datetime_widget, false), preferences.getBoolean(
task) R.string.p_auto_dismiss_datetime_widget,
false
),
task
)
.show(fragmentManager, FRAG_TAG_DATE_TIME_PICKER) .show(fragmentManager, FRAG_TAG_DATE_TIME_PICKER)
} else {
finish()
}
}
} }
} }
TOGGLE_GROUP -> { TOGGLE_GROUP -> {
@ -101,18 +122,16 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
collapsed.remove(group) collapsed.remove(group)
} }
widgetPreferences.collapsed = collapsed widgetPreferences.collapsed = collapsed
localBroadcastManager.broadcastRefresh() refreshBroadcaster.broadcastRefresh()
finish() finish()
} }
else -> { else -> {
Timber.e("Unknown action $action") Timber.e("Unknown action $action")
finish()
} }
} }
} }
val task: Task
get() = intent.getParcelableExtra(EXTRA_TASK)!!
override fun onDismiss() { override fun onDismiss() {
finish() finish()
} }
@ -125,8 +144,9 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
const val RESCHEDULE_TASK = "RESCHEDULE_TASK" const val RESCHEDULE_TASK = "RESCHEDULE_TASK"
const val TOGGLE_GROUP = "TOGGLE_GROUP" const val TOGGLE_GROUP = "TOGGLE_GROUP"
const val EXTRA_FILTER = "extra_filter" const val EXTRA_FILTER = "extra_filter"
const val EXTRA_TASK = "extra_task" // $NON-NLS-1$ const val EXTRA_TASK_ID = "extra_task_id"
const val EXTRA_COLLAPSED = "extra_collapsed" const val EXTRA_COLLAPSED = "extra_collapsed"
const val EXTRA_COMPLETED = "extra_completed"
const val EXTRA_GROUP = "extra_group" const val EXTRA_GROUP = "extra_group"
const val EXTRA_WIDGET = "extra_widget" const val EXTRA_WIDGET = "extra_widget"
private const val FRAG_TAG_DATE_TIME_PICKER = "frag_tag_date_time_picker" private const val FRAG_TAG_DATE_TIME_PICKER = "frag_tag_date_time_picker"

@ -0,0 +1,118 @@
package org.tasks.widget
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.sizeDp
import org.tasks.BuildConfig
import org.tasks.icons.OutlinedGoogleMaterial
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
class WidgetIconProvider : ContentProvider() {
override fun onCreate() = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r") {
throw SecurityException("Only read access allowed")
}
return try {
val segments = uri.pathSegments
if (segments.size != 2) return null
val iconName = segments[1]
if (!iconName.matches(Regex("^[a-zA-Z0-9_]+$"))) return null
val cacheFile = getCacheFile(iconName)
if (!cacheFile.exists()) {
generateIcon(cacheFile, iconName)
}
if (cacheFile.exists()) {
ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY)
} else {
null
}
} catch (e: Exception) {
Timber.e(e, "Failed to open icon file for URI: $uri")
null
}
}
private fun generateIcon(file: File, iconName: String) {
try {
val icon = OutlinedGoogleMaterial.getIcon("gmo_$iconName")
val context = context ?: return
val drawable = IconicsDrawable(context, icon).apply {
this.sizeDp = 24
}
val bitmap = createBitmap(
drawable.intrinsicWidth.coerceAtLeast(1),
drawable.intrinsicHeight.coerceAtLeast(1)
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
file.parentFile?.mkdirs()
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
bitmap.recycle()
} catch (e: Exception) {
Timber.e(e, "Failed to generate icon: $iconName")
file.delete()
}
}
private fun getCacheFile(iconName: String): File {
val context = context ?: throw IllegalStateException("Context is null")
val cacheDir = File(context.cacheDir, "widget_icons")
cacheDir.mkdirs()
return File(cacheDir, "${iconName}.png")
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun getType(uri: Uri): String = "image/png"
companion object {
const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.widgeticons"
fun getIconUri(iconName: String): Uri {
return "content://$AUTHORITY/icon/$iconName".toUri()
}
}
}

@ -109,4 +109,7 @@
<string name="CFC_importance_name">Prioritet…</string> <string name="CFC_importance_name">Prioritet…</string>
<string name="week_before_due">Sedmica prije roka</string> <string name="week_before_due">Sedmica prije roka</string>
<string name="TEA_control_repeat">Ponovi</string> <string name="TEA_control_repeat">Ponovi</string>
<string name="app_settings">Postavke aplikacije</string>
<string name="customize_drawer">Prilagodi meni</string>
<string name="customize_drawer_summary">Povuci i pusti za promjenu rasporeda u meniju</string>
</resources> </resources>

@ -355,8 +355,8 @@
<string name="location_remind_arrival">Bei Ankunft erinnern</string> <string name="location_remind_arrival">Bei Ankunft erinnern</string>
<string name="location_remind_departure">Bei Abreise erinnern</string> <string name="location_remind_departure">Bei Abreise erinnern</string>
<string name="visit_website">Website öffnen</string> <string name="visit_website">Website öffnen</string>
<string name="location_arrived">Angekommen um %s</string> <string name="location_arrived">%s erreicht</string>
<string name="location_departed">Abgereist um %s</string> <string name="location_departed">%s verlassen</string>
<string name="building_notifications">Benachrichtigungen generieren</string> <string name="building_notifications">Benachrichtigungen generieren</string>
<string name="choose_a_location">Ort auswählen</string> <string name="choose_a_location">Ort auswählen</string>
<string name="pick_this_location">Diesen Ort auswählen</string> <string name="pick_this_location">Diesen Ort auswählen</string>

@ -371,7 +371,7 @@
<string name="cannot_access_account">Tiliin ei päästä käsiksi</string> <string name="cannot_access_account">Tiliin ei päästä käsiksi</string>
<string name="logout">Kirjaudu ulos</string> <string name="logout">Kirjaudu ulos</string>
<string name="this_feature_requires_a_subscription">Tämä ominaisuus vaatii tilauksen</string> <string name="this_feature_requires_a_subscription">Tämä ominaisuus vaatii tilauksen</string>
<string name="requires_pro_subscription">Edellyttää ammattilaistilauksen</string> <string name="requires_pro_subscription">Edellyttää pro-tilauksen</string>
<string name="license_summary">Tasks on vapaa avoimen ohjelmakoodin ohjelmisto lisensöity GNU General Public License v3.0 -lisenssillä</string> <string name="license_summary">Tasks on vapaa avoimen ohjelmakoodin ohjelmisto lisensöity GNU General Public License v3.0 -lisenssillä</string>
<string name="about">Tietoja sovelluksesta</string> <string name="about">Tietoja sovelluksesta</string>
<string name="button_unsubscribe">Peruuta tilaus</string> <string name="button_unsubscribe">Peruuta tilaus</string>
@ -696,4 +696,30 @@
<string name="swipe_to_snooze_time_immediately">välittömästi</string> <string name="swipe_to_snooze_time_immediately">välittömästi</string>
<string name="enable_alarms">Saat ilmoituksen oikeaan aikaan</string> <string name="enable_alarms">Saat ilmoituksen oikeaan aikaan</string>
<string name="enable_alarms_description">Varmista, että saat ilmoituksen oikeaan aikaan, myöntämällä lupa asettaa hälytyksiä ja muistutuksia Asetuksissa</string> <string name="enable_alarms_description">Varmista, että saat ilmoituksen oikeaan aikaan, myöntämällä lupa asettaa hälytyksiä ja muistutuksia Asetuksissa</string>
<string name="app_settings">Sovellusasetukset</string>
<string name="delete_comment">kommentti</string>
<string name="comment">Kommentti</string>
<string name="yesterday">Eilen</string>
<string name="continue_without_sync">Jatka ilman synkronointia</string>
<string name="help_me_choose">Auta minua valitsemaan</string>
<string name="delete_tasks_warning">%s poistetaan. Tätä ei voi perua!</string>
<string name="banner_app_updated_title">Sovellus päivitetty</string>
<string name="banner_app_updated_description">Sovellus päivitettiin juuri %s. Haluatko lukea julkaisutiedot?</string>
<string name="subtasks_multilevel_microsoft">Microsoft To Do ei tue monitasoisia alitehtäviä</string>
<string name="price_per_month_with_currency">%s/kuukausi</string>
<string name="price_per_year_with_currency">%s/vuosi</string>
<string name="add_shortcut_to_home_screen">Lisää pikakuvake aloitusnäytölle</string>
<string name="add_widget_to_home_screen">Lisää pienoissovellus aloitusnäytölle</string>
<string name="cost_free">Hinta: Ilmainen</string>
<string name="cost_money">Hinta: $</string>
<string name="cost_more_money">Hinta: $$$</string>
<string name="multiline_title">Salli moniriviset otsikot</string>
<string name="multiline_title_on">Paina Enter-näppäintä lisätäksesi rivinvaihdon</string>
<string name="multiline_title_off">Paina Valmis tallentaaksesi tehtävän</string>
<string name="sync_warning_microsoft_title">Microsoft To Do synkronoinnista</string>
<string name="sync_warning_microsoft">Kaikkia tehtävän tietoja ei voi synkronoida Microsoft To Do:n kanssa.</string>
<string name="sync_warning_google_tasks_title">Google Taskista</string>
<string name="sync_warning_google_tasks">Kaikkia tehtävän tietoja ei voi synkronoida Google Taskin kanssa</string>
<string name="button_learn_more">Lue lisää</string>
<string name="widget_view_more_tasks">Lisää tehtäviä</string>
</resources> </resources>

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>

@ -743,4 +743,5 @@
<string name="banner_app_updated_title">Aplikacija je aktualizirana</string> <string name="banner_app_updated_title">Aplikacija je aktualizirana</string>
<string name="banner_app_updated_description">Aplikacija Tasks je upravo aktualizirana na %s. Želiš li vidjeti bilješke o izdanju?</string> <string name="banner_app_updated_description">Aplikacija Tasks je upravo aktualizirana na %s. Želiš li vidjeti bilješke o izdanju?</string>
<string name="multiline_title_off">Pritisni „Gotovo” za spremanje zadatka</string> <string name="multiline_title_off">Pritisni „Gotovo” za spremanje zadatka</string>
<string name="widget_view_more_tasks">Prikaži više zadataka</string>
</resources> </resources>

@ -9,7 +9,7 @@
<string name="import_summary_title">Riepilogo del ripristino</string> <string name="import_summary_title">Riepilogo del ripristino</string>
<string name="import_summary_message">Il file %1$s contiene %2$s. \n \n %3$s importate, \n %4$s già esistenti \n %5$s con errori</string> <string name="import_summary_message">Il file %1$s contiene %2$s. \n \n %3$s importate, \n %4$s già esistenti \n %5$s con errori</string>
<string name="import_progress_read">Lettura attività %d…</string> <string name="import_progress_read">Lettura attività %d…</string>
<string name="read_permission_label">accedere a Tasks</string> <string name="read_permission_label">Permessi di Tasks</string>
<string name="discard_confirmation">Vuoi davvero annullare tutte le modifiche\?</string> <string name="discard_confirmation">Vuoi davvero annullare tutte le modifiche\?</string>
<string name="keep_editing">No, continua la modifica</string> <string name="keep_editing">No, continua la modifica</string>
<string name="DLG_delete_this_task_question">Eliminare questa attività\?</string> <string name="DLG_delete_this_task_question">Eliminare questa attività\?</string>
@ -451,8 +451,8 @@
<string name="location_radius_meters">%s m</string> <string name="location_radius_meters">%s m</string>
<string name="subtasks">Attività secondaria</string> <string name="subtasks">Attività secondaria</string>
<string name="TEA_timer_controls">Timer</string> <string name="TEA_timer_controls">Timer</string>
<string name="chip_appearance">Aspetto etichetta</string> <string name="chip_appearance">Aspetto Chip</string>
<string name="chips">Smart Chips</string> <string name="chips">Chip</string>
<string name="custom_filter_not">NON</string> <string name="custom_filter_not">NON</string>
<string name="custom_filter_or">O</string> <string name="custom_filter_or">O</string>
<string name="custom_filter_and">E</string> <string name="custom_filter_and">E</string>

@ -111,7 +111,7 @@
<string name="ring_once">Een keer bellen</string> <string name="ring_once">Een keer bellen</string>
<string name="ring_five_times">Vijf keer bellen</string> <string name="ring_five_times">Vijf keer bellen</string>
<string name="ring_nonstop">Non-stop bellen</string> <string name="ring_nonstop">Non-stop bellen</string>
<string name="rmd_NoA_done">Gereed</string> <string name="rmd_NoA_done">Voltooid</string>
<string name="rmd_NoA_snooze">Snoozen</string> <string name="rmd_NoA_snooze">Snoozen</string>
<string name="snooze_all">Alles snoozen</string> <string name="snooze_all">Alles snoozen</string>
<string name="rmd_EPr_quiet_hours_start_title">Begin rusttijd</string> <string name="rmd_EPr_quiet_hours_start_title">Begin rusttijd</string>

@ -672,7 +672,7 @@
<string name="enable_reminders">Activează memento-uri</string> <string name="enable_reminders">Activează memento-uri</string>
<string name="enable_reminders_description">Reamintirile sunt dezactivate în Setări Android</string> <string name="enable_reminders_description">Reamintirile sunt dezactivate în Setări Android</string>
<string name="TEA_creation_date">Data creării</string> <string name="TEA_creation_date">Data creării</string>
<string name="default_reminder">Reamintire implicită</string> <string name="default_reminder">Memento implicit</string>
<string name="rmd_time_description">Afișează notificări pentru sarcinile fără termene limită</string> <string name="rmd_time_description">Afișează notificări pentru sarcinile fără termene limită</string>
<string name="consent_agree">De acord</string> <string name="consent_agree">De acord</string>
<string name="consent_deny">Nu acum</string> <string name="consent_deny">Nu acum</string>
@ -748,4 +748,5 @@
<string name="delete_tasks_warning">%s va fi șters. Acest lucru nu poate fi anulat!</string> <string name="delete_tasks_warning">%s va fi șters. Acest lucru nu poate fi anulat!</string>
<string name="banner_app_updated_title">Aplicație actualizată</string> <string name="banner_app_updated_title">Aplicație actualizată</string>
<string name="banner_app_updated_description">Tasks a fost actualizată la versiunea %s. Dorești să vezi modificările făcute?</string> <string name="banner_app_updated_description">Tasks a fost actualizată la versiunea %s. Dorești să vezi modificările făcute?</string>
<string name="widget_view_more_tasks">Vezi mai multe sarcini</string>
</resources> </resources>

@ -1,30 +1,30 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="SSD_sort_auto">Pametno sortiranje</string> <string name="SSD_sort_auto">Паметно сортирање</string>
<string name="SSD_sort_alpha">Po naslovu</string> <string name="SSD_sort_alpha">По наслову</string>
<string name="SSD_sort_due">Po datumu</string> <string name="SSD_sort_due">По датуму доспећа</string>
<string name="SSD_sort_importance">Po prioritetnosti</string> <string name="SSD_sort_importance">По приоритету</string>
<string name="SSD_sort_modified">Po zadnjoj izmeni</string> <string name="SSD_sort_modified">По датуму модификације</string>
<string name="TEA_title_hint">Ime zadatka</string> <string name="TEA_title_hint">Име задатка</string>
<string name="TEA_importance_label">Prioritet</string> <string name="TEA_importance_label">Приоритет</string>
<string name="custom_filter_or">ili</string> <string name="custom_filter_or">Или</string>
<string name="custom_filter_not">Ne</string> <string name="custom_filter_not">Не</string>
<string name="custom_filter_and">takođe</string> <string name="custom_filter_and">Такође</string>
<string name="CFA_button_add">Dodati kriterijum</string> <string name="CFA_button_add">Додај критеријум</string>
<string name="CFC_dueBefore_text">Po datumu: ?</string> <string name="CFC_dueBefore_text">По датуму: ?</string>
<string name="gcal_completed_title">%s (završeno)</string> <string name="gcal_completed_title">%s (завршено)</string>
<string name="rmd_EPr_defaultRemind_title">Nasumice posetnik</string> <string name="rmd_EPr_defaultRemind_title">Насумични подсетници</string>
<string name="delete_task">Obriši zadatak</string> <string name="delete_task">Обриши задатак</string>
<string name="contact_developer">Kontaktiraj razvojni tim</string> <string name="contact_developer">Контактирај развојни тим</string>
<string name="rate_tasks">Oceni Task</string> <string name="rate_tasks">Оцени апликацију</string>
<string name="quiet_hours_summary">Bez podsetnika u mirnim satima</string> <string name="quiet_hours_summary">Без подсетника у мирним сатима</string>
<string name="filter_settings">Подешавања филтера</string> <string name="filter_settings">Подешавања филтера</string>
<string name="import_summary_title">Обнови сажетак</string> <string name="import_summary_title">Обнови сажетак</string>
<string name="export_toast">Копија сачувана од %1$s до %2$s.</string> <string name="export_toast">Копија сачувана од %1$s до %2$s.</string>
<string name="backup_BAc_import">Увези задатке</string> <string name="backup_BAc_import">Увези задатке</string>
<string name="backup_BAc_export">Извези задатке</string> <string name="backup_BAc_export">Извези задатке</string>
<string name="backup_BPr_header">Резервне копије</string> <string name="backup_BPr_header">Резервне копије</string>
<string name="TVA_add_comment">Додајте коментар…</string> <string name="TVA_add_comment">Додај коментар…</string>
<string name="cancel">Откажи</string> <string name="cancel">Откажи</string>
<string name="ok">У реду</string> <string name="ok">У реду</string>
<string name="read_permission_label">Дозволе задатака</string> <string name="read_permission_label">Дозволе задатака</string>
@ -408,7 +408,7 @@
<string name="button_subscribe">Претплати се</string> <string name="button_subscribe">Претплати се</string>
<string name="button_unsubscribe">Откажи претплату</string> <string name="button_unsubscribe">Откажи претплату</string>
<string name="about">О нама</string> <string name="about">О нама</string>
<string name="license_summary">,,Задаци\'\' је софтвер отвореног карактера, са лиценцом ,,GNU General Public License v3.0\'\'</string> <string name="license_summary">,,Tasks.org\'\' је софтвер отвореног карактера, са лиценцом ,,GNU General Public License v3.0\'\'</string>
<string name="requires_pro_subscription">Захтева претплату</string> <string name="requires_pro_subscription">Захтева претплату</string>
<string name="this_feature_requires_a_subscription">Ова функционалност захтева претплату</string> <string name="this_feature_requires_a_subscription">Ова функционалност захтева претплату</string>
<string name="logout">Одјави се</string> <string name="logout">Одјави се</string>
@ -426,7 +426,7 @@
<string name="choose_a_location">Одабери локацију</string> <string name="choose_a_location">Одабери локацију</string>
<string name="pick_this_location">Одабери ову локацију</string> <string name="pick_this_location">Одабери ову локацију</string>
<string name="or_choose_a_location">Или бирај локацију</string> <string name="or_choose_a_location">Или бирај локацију</string>
<string name="background_location_permission_required">,,Задаци\'\' скупља податке о локацији како би локационо везани подсетници радили, чак и када је апликација угашена или се не користи.</string> <string name="background_location_permission_required">,,Tasks.org\'\' скупља податке о локацији како би локационо везани подсетници радили, чак и када је апликација угашена или се не користи.</string>
<string name="location_permission_required_location">Потребна је локациона дозвола за налажење тренутне локације</string> <string name="location_permission_required_location">Потребна је локациона дозвола за налажење тренутне локације</string>
<string name="open_map">Отвори мапу</string> <string name="open_map">Отвори мапу</string>
<string name="choose_new_location">Одабери нову локацију</string> <string name="choose_new_location">Одабери нову локацију</string>
@ -453,7 +453,7 @@
<string name="more_settings">Додатна подешавања</string> <string name="more_settings">Додатна подешавања</string>
<string name="more_notification_settings_summary">Звук звона, вибрација и додатно</string> <string name="more_notification_settings_summary">Звук звона, вибрација и додатно</string>
<string name="invalid_username_or_password">Невалидно корисничко име или шифра</string> <string name="invalid_username_or_password">Невалидно корисничко име или шифра</string>
<string name="upgrade_blurb_1">Здраво! Моје име је Алекс. Ја сам независни развојни инжењер у ,,Задаци\'\' апликацији</string> <string name="upgrade_blurb_1">Здраво! Моје име је Алекс. Ја сам независни развојни инжењер у ,,Tasks.org\'\' апликацији</string>
<string name="upgrade_blurb_2">Провео сам хиљаде часова радећи на апликацији, коју објављујем онлајн бесплатно. Како бисте подржали мој рад, неке функционалности захтевају претплату</string> <string name="upgrade_blurb_2">Провео сам хиљаде часова радећи на апликацији, коју објављујем онлајн бесплатно. Како бисте подржали мој рад, неке функционалности захтевају претплату</string>
<string name="back">Назад</string> <string name="back">Назад</string>
<string name="places">Места</string> <string name="places">Места</string>
@ -743,4 +743,5 @@
<string name="task_defaults">Подразумевано за задатке</string> <string name="task_defaults">Подразумевано за задатке</string>
<string name="CFC_importance_text">Минимум приоритета ?</string> <string name="CFC_importance_text">Минимум приоритета ?</string>
<string name="caldav_home_set_not_found">Сет није пронађен</string> <string name="caldav_home_set_not_found">Сет није пронађен</string>
<string name="widget_view_more_tasks">Погледај још задатака</string>
</resources> </resources>

@ -721,4 +721,5 @@
<string name="help_me_choose">Hjälp mig att välja</string> <string name="help_me_choose">Hjälp mig att välja</string>
<string name="delete_tasks_warning">%s kommer att tas bort. Detta kan inte ångras!</string> <string name="delete_tasks_warning">%s kommer att tas bort. Detta kan inte ångras!</string>
<string name="continue_without_sync">Fortsätt utan synkronisering</string> <string name="continue_without_sync">Fortsätt utan synkronisering</string>
<string name="widget_view_more_tasks">Visa mer uppgifter</string>
</resources> </resources>

@ -29,8 +29,10 @@
<locale android:name="hy" /> <locale android:name="hy" />
<locale android:name="ia" /> <locale android:name="ia" />
<locale android:name="id" /> <locale android:name="id" />
<locale android:name="in" />
<locale android:name="it" /> <locale android:name="it" />
<locale android:name="iw" /> <locale android:name="iw" />
<locale android:name="he" />
<locale android:name="ja" /> <locale android:name="ja" />
<locale android:name="kmr" /> <locale android:name="kmr" />
<locale android:name="kn" /> <locale android:name="kn" />

@ -13,6 +13,6 @@
android:minResizeWidth="110dp" android:minResizeWidth="110dp"
android:minWidth="110dp" android:minWidth="110dp"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="0"
android:widgetCategory="keyguard|home_screen" android:widgetCategory="keyguard|home_screen"
tools:ignore="UnusedAttribute"/> tools:ignore="UnusedAttribute"/>

@ -295,7 +295,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleOverdueRandomReminder() { fun scheduleOverdueRandomReminder() {
random.seed = 0.3865f random.stub = 0.3865f
freezeAt(now) { freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry( val alarm = alarmCalculator.toAlarmEntry(
newTask( newTask(
@ -316,7 +316,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleOverdueRandomReminderForHiddenTask() { fun scheduleOverdueRandomReminderForHiddenTask() {
random.seed = 0.3865f random.stub = 0.3865f
freezeAt(now) { freezeAt(now) {
val task = newTask( val task = newTask(
with(REMINDER_LAST, now.minusDays(14)), with(REMINDER_LAST, now.minusDays(14)),
@ -335,7 +335,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleInitialRandomReminder() { fun scheduleInitialRandomReminder() {
random.seed = 0.3865f random.stub = 0.3865f
freezeAt(now) { freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry( val alarm = alarmCalculator.toAlarmEntry(
@ -358,7 +358,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleNextRandomReminder() { fun scheduleNextRandomReminder() {
random.seed = 0.3865f random.stub = 0.3865f
freezeAt(now) { freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry( val alarm = alarmCalculator.toAlarmEntry(
@ -379,9 +379,28 @@ class AlarmCalculatorTest {
} }
} }
@Test
fun randomReminderIsDeterministic() {
val calculator = AlarmCalculator(
isDefaultDueTimeEnabled = true,
random = Random(),
defaultDueTime = TimeUnit.HOURS.toMillis(13).toInt(),
)
freezeAt(now) {
val task = newTask(with(CREATION_TIME, now.minusDays(1)))
val alarm = Alarm(time = ONE_WEEK, type = TYPE_RANDOM)
val first = calculator.toAlarmEntry(task, alarm)
val second = calculator.toAlarmEntry(task, alarm)
assertEquals(first, second)
}
}
internal class RandomStub : Random() { internal class RandomStub : Random() {
var seed = 1.0f var stub = 1.0f
override fun nextFloat() = seed override fun nextFloat(seed: Long) = this.stub
} }
} }

@ -1,13 +1,19 @@
package org.tasks.caldav.extensions package org.tasks.caldav.extensions
import at.bitfire.ical4android.Task.Companion.tasksFromReader
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.tasks.caldav.iCalendar.Companion.applyLocal
import org.tasks.data.createDueDate
import org.tasks.data.entity.Alarm import org.tasks.data.entity.Alarm
import org.tasks.data.entity.Alarm.Companion.TYPE_DATE_TIME import org.tasks.data.entity.Alarm.Companion.TYPE_DATE_TIME
import org.tasks.data.entity.Alarm.Companion.TYPE_REL_END import org.tasks.data.entity.Alarm.Companion.TYPE_REL_END
import org.tasks.data.entity.Alarm.Companion.TYPE_REL_START import org.tasks.data.entity.Alarm.Companion.TYPE_REL_START
import org.tasks.data.entity.CaldavTask
import org.tasks.data.entity.Task
import org.tasks.time.DateTime import org.tasks.time.DateTime
import org.tasks.time.DateTime.Companion.UTC import org.tasks.time.DateTime.Companion.UTC
import java.io.ByteArrayInputStream
import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.HOURS
import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.TimeUnit.MINUTES
@ -111,4 +117,34 @@ class VAlarmTests {
) )
assertEquals(alarm, alarm.toVAlarm()?.toAlarm()) assertEquals(alarm, alarm.toVAlarm()?.toAlarm())
} }
@Test
fun serializeAlarms() {
val remoteTask = at.bitfire.ical4android.Task()
remoteTask.applyLocal(
CaldavTask(
calendar = "",
),
Task(
dueDate = createDueDate(
Task.URGENCY_SPECIFIC_DAY_TIME,
DateTime(2025, 9, 4, 18, 0, 0).millis
),
),
)
Alarm(time = 0, type = TYPE_REL_END).toVAlarm()?.let { remoteTask.alarms.add(it) }
val os = java.io.ByteArrayOutputStream()
remoteTask.write(os)
val tasks = tasksFromReader(ByteArrayInputStream(os.toByteArray()).reader())
assertEquals(1, tasks.size)
val task = tasks.first()
assertEquals(1, task.alarms.size)
val alarm = task.alarms.first().toAlarm()
assertEquals(TYPE_REL_END, alarm?.type)
assertEquals(0L, alarm?.time)
}
} }

@ -91,7 +91,7 @@ class ConvertToMicrosoftTests {
@Test @Test
fun noCategories() { fun noCategories() {
val remote = newTask().toRemote() val remote = newTask().toRemote()
assertNull(remote.categories) assertEquals(true, remote.categories?.isEmpty())
} }
@Test @Test

@ -83,8 +83,15 @@ abstract class GoogleTaskDao {
@Query("SELECT * FROM caldav_tasks WHERE cd_remote_id = :remoteId LIMIT 1") @Query("SELECT * FROM caldav_tasks WHERE cd_remote_id = :remoteId LIMIT 1")
abstract suspend fun getByRemoteId(remoteId: String): CaldavTask? abstract suspend fun getByRemoteId(remoteId: String): CaldavTask?
@Query("SELECT * FROM caldav_tasks WHERE cd_task = :taskId AND cd_deleted > 0") @Query("""
abstract suspend fun getDeletedByTaskId(taskId: Long): List<CaldavTask> SELECT caldav_tasks.*
FROM caldav_tasks
INNER JOIN caldav_lists ON cdl_uuid = cd_calendar
WHERE cd_task = :taskId
AND cd_deleted > 0
AND cdl_account = :account
""")
abstract suspend fun getDeletedByTaskId(taskId: Long, account: String): List<CaldavTask>
@Query("SELECT * FROM caldav_tasks WHERE cd_task = :taskId") @Query("SELECT * FROM caldav_tasks WHERE cd_task = :taskId")
abstract suspend fun getAllByTaskId(taskId: Long): List<CaldavTask> abstract suspend fun getAllByTaskId(taskId: Long): List<CaldavTask>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,9 @@
* Fix blank widgets on Android 16 QPR1
* Fix all-day calendar events
* Fix alarm synchronization
* Fix sync failure when migrating data from EteSync to CalDAV
* Fix removing values from Microsoft To Do
* Fix share invites for Nextcloud
* Fix failure to delete source data when moving to Google Tasks
* Fix crash when clearing completed while grouping by lists
* Update translations

@ -0,0 +1 @@
Initial WearOS release - work in progress!

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save