Compare commits

..

No commits in common. 'main' and '14.6.2' have entirely different histories.
main ... 14.6.2

@ -20,16 +20,16 @@ jobs:
- name: Decode Keystore - name: Decode Keystore
run: | run: |
echo ${{ secrets.KEY_STORE }} | base64 -di > "${RUNNER_TEMP}"/keystore.jks echo ${{ secrets.KEY_STORE }} | base64 -di > "${RUNNER_TEMP}"/keystore.jks
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '17'
- 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@v5 uses: actions/upload-artifact@v4
with: with:
name: release name: release
path: | path: |

@ -11,16 +11,16 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- name: Set up JDK 21 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '17'
- 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@v5 uses: actions/upload-artifact@v4
if: ${{ always() }} if: ${{ always() }}
with: with:
name: lint-reports name: lint-reports
@ -43,13 +43,13 @@ jobs:
api-level: [29] api-level: [29]
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '17'
- 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@v5 uses: actions/upload-artifact@v4
if: ${{ always() }} if: ${{ always() }}
with: with:
name: test-reports-${{ matrix.flavor }} name: test-reports-${{ matrix.flavor }}

@ -22,15 +22,15 @@ jobs:
contents: write contents: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
with: with:
ref: ${{ github.head_ref }} ref: ${{ github.head_ref }}
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '17'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4

@ -17,14 +17,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ bundle ] needs: [ bundle ]
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Fastlane key - name: Fastlane key
run: | run: |
echo "$FASTLANE" > ./fastlane.json echo "$FASTLANE" > ./fastlane.json
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v4
with: with:
name: release name: release
path: . path: .

@ -1 +1 @@
3.4.7 3.3.6

@ -1,179 +1,3 @@
### 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)
* System bar scrim improvements
* Recover from Google Task 'Bad request' errors
* Improve layout on Z Folds
* Crash fixes
* Update translations
* Brazilian Portuguese - odnankenobi
* Catalan - @Crashillo, @ferranpujolcamins
* Danish - ERYpTION
* Esperanto - Don Zouras
* Galician - @Crashillo, @delthia
* Hungarian - @Antmajgra, @gthrepwood
* Italian - @ppasserini
* Korean - Jiho Min
* Polish - @Antmajgra
* Portuguese - @Crashillo
* Russian - Алексей Ежков
* Spanish - @Crashillo
### 14.8 (2025-08-02)
* Synchronize **list** icons for Tasks.org and CalDAV accounts
* Does not apply to Microsoft To Do, Google Tasks, DAVx5, EteSync, or DecSync
CC accounts
* Does not apply to tags or filters
* CalDAV server must support extensible properties, e.g. Nextcloud or sabre/dav
* Target Android 15
* Return to previous view after searching
* Remove shadow from date picker sheet
* Fix updating list names and colors for Tasks.org and CalDAV accounts
* Update translations
* Bulgarian - 109247019824
* Chinese (Simplified) - Sketch6580
* Czech - @Fjuro
* Dutch - @fvbommel
* Estonian - Priit Jõerüüt
* French - @FlorianLeChat
* German - @Colorful Rhino
* Hebrew - Xo
* Italian - @ppasserini
* Turkish - @emintufan
* Ukrainian - @IhorHordiichuk
### 14.7.4 (2025-07-12)
* @devn1x: Fix escaping quotes in iCalendar [#3645](https://github.com/tasks/tasks/pull/3645)
* Limit widget to 25 items on Android 16+
* Android 16 nerfed widget performance 😢
* Fix bug when reconfiguring widget
* Fix default widget group sort order
* Update translations
* Catalan - pitroig
* Chinese (Simplified) - 大王叫我来巡山
* Croatian - @milotype
* German - @Kachelkaiser
* Serbian - @vale-decem
* Swedish - Nick Wick
* Tamil - @TamilNeram
### 14.7.3 (2025-06-13)
* Fix dynamic color
* Fix Microsoft To Do sync failure
* Fix crash after deleting last list
* Fix notifications when 'Alarms & reminders' not allowed
* Update translations
* Bulgarian - 109247019824
* Dutch - @fvbommel
* Esperanto - Don Zouras
* French - @FlorianLeChat
* Hebrew - Xo
* Japanese - M_Haruki
* Persian - @theuser17
* Portuguese - @nero-bratti
* Romanian - @ygorigor
* Russian - @yurtpage
* Spanish - @orionn333
* Swedish - @Nicklasfox
* Turkish - @emintufan
### 14.7.2 (2025-05-23)
* Remove Microsoft Authentication Library from F-Droid builds [#3581](https://github.com/tasks/tasks/issues/3581)
* Remove contacts permission added by Microsoft Authentication Library
* Enable video attachments
* Fix wallpaper theme
* Fix handling multiple attachments
* Update translations
* Arabic - abdelbasset jabrane
* Bulgarian - 109247019824
* Catalan - @Crashillo
* Czech - @Fjuro
* Danish - @catsnote
* Dutch - @fvbommel
* Esperanto - Don Zouras
* Estonian - Priit Jõerüüt
* Hungarian - Kaci
* Italian - @ppasserini
* Turkish - @emintufan
* Ukrainian - @IhorHordiichuk
### 14.7.1 (2025-05-04)
* Fix app closing itself automatically [#3366](https://github.com/tasks/tasks/issues/3366)
* Automatically set default list when connecting Microsoft To Do account
* Update translations
* Arabic - abdelbasset jabrane, @kemo-1
* Brazilian Portuguese - Jose Delvani
* Chinese (Simplified) - Sketch6580
* French - @FlorianLeChat
* German - @Kachelkaiser
### 14.7 (2025-05-03)
* Add support for Microsoft To Do work & school accounts [#3267](https://github.com/tasks/tasks/issues/3267)
* Add ability to rename or delete local account
* Prompt to sign in or import backup on first launch
* @BeaterGhalio: Fix back button closing app after search [#3426](https://github.com/tasks/tasks/issues/3426)
* @codokie: Automirrored icons fix [#3499](https://github.com/tasks/tasks/pull/3499)
* @codokie: Fix ltr-rtl alignment for text input [#3489](https://github.com/tasks/tasks/pull/3489)
* Use system language picker on Android 33+
* Don't show 'due date' as a start date option for DAVx5, EteSync, DecSync CC [#1558](https://github.com/tasks/tasks/issues/1558)
* Prevent attempts to delete or rename Microsoft To Do default list
* Don't handle system 'Clear storage' button
* Update minimum Android version to 8
* Fix backup import dropping tags [#3556](https://github.com/tasks/tasks/issues/3556)
* Fix start date chip when grouping by start date [#3509](https://github.com/tasks/tasks/issues/3509)
* Update translations
* Brazilian Portuguese - @sobeitnow0, dedakir923
* Czech - @Fjuro
* Dutch - Jay Tromp
* German - min7-i
* Hebrew - Xo
* Portuguese - @wm-pucrs
* Russian - @hady-exc, Maksim_220 Кабанов
* Slovak - @jose1711
* Spanish - Nucl3arSnake, @diamondtipdr
* Tamil - @TamilNeram
### 14.6.2 (2025-04-06) ### 14.6.2 (2025-04-06)
* Show error indicators if 'When started' or 'When due' reminders are used * Show error indicators if 'When started' or 'When due' reminders are used

@ -1,4 +1,3 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "fastlane" gem "fastlane"
gem "abbrev"

@ -5,31 +5,29 @@ GEM
base64 base64
nkf nkf
rexml rexml
abbrev (0.1.2)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.4.0) aws-eventstream (1.3.2)
aws-partitions (1.1122.0) aws-partitions (1.1067.0)
aws-sdk-core (3.226.1) aws-sdk-core (3.220.1)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
base64 base64
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger aws-sdk-kms (1.99.0)
aws-sdk-kms (1.106.0) aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.191.0) aws-sdk-s3 (1.182.0)
aws-sdk-core (~> 3, >= 3.225.0) aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
base64 (0.3.0) base64 (0.2.0)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
@ -58,10 +56,10 @@ GEM
faraday (>= 0.8.0) faraday (>= 0.8.0)
http-cookie (~> 1.0.0) http-cookie (~> 1.0.0)
faraday-em_http (1.0.0) faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1) faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0) faraday-excon (1.1.0)
faraday-httpclient (1.0.1) faraday-httpclient (1.0.1)
faraday-multipart (1.1.1) faraday-multipart (1.1.0)
multipart-post (~> 2.0) multipart-post (~> 2.0)
faraday-net_http (1.0.2) faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
@ -71,7 +69,7 @@ GEM
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.0) fastimage (2.4.0)
fastlane (2.228.0) fastlane (2.227.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
@ -111,7 +109,7 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0) word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0) xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1) xcpretty (~> 0.4.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0) fastlane-sirp (1.0.0)
sysrandom (~> 1.0) sysrandom (~> 1.0)
@ -158,23 +156,22 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.12.2) json (2.10.2)
jwt (2.10.2) jwt (2.10.1)
base64 base64
logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
naturally (2.3.0) naturally (2.2.1)
nkf (0.2.0) nkf (0.2.0)
optparse (0.6.0) optparse (0.6.0)
os (1.1.4) os (1.1.4)
plist (3.7.2) plist (3.7.2)
public_suffix (6.0.2) public_suffix (5.1.1)
rake (13.3.0) rake (13.2.1)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
@ -185,7 +182,7 @@ GEM
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (2.4.1)
security (0.1.5) security (0.1.5)
signet (0.20.0) signet (0.19.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
@ -212,7 +209,7 @@ GEM
colored2 (~> 3.1) colored2 (~> 3.1)
nanaimo (~> 0.4.0) nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0) rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1) xcpretty (0.4.0)
rouge (~> 3.28.0) rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1) xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7) xcpretty (~> 0.2, >= 0.0.7)
@ -221,8 +218,7 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
abbrev
fastlane fastlane
BUNDLED WITH BUNDLED WITH
2.6.9 2.2.32

@ -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) [![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)
### Contributing ### Contributing
@ -23,6 +23,8 @@ Contributions are always welcome! Whether translations, code changes, bug report
### Communication ### Communication
You can submit questions to [GitHub Discussions](https://github.com/tasks/tasks/discussions). Join the #tasks channel on Libera Chat to chat with the Tasks team and other people. [Link to webchat](https://web.libera.chat/#tasks)
You can also use [GitHub Discussions](https://github.com/tasks/tasks/discussions).
If you have a suggestion or want to report a bug, please see [CONTRIBUTING.md](CONTRIBUTING.md). If you have a suggestion or want to report a bug, please see [CONTRIBUTING.md](CONTRIBUTING.md).

@ -154,7 +154,6 @@ dependencies {
implementation(projects.data) implementation(projects.data)
implementation(projects.kmp) implementation(projects.kmp)
implementation(projects.icons) implementation(projects.icons)
implementation(libs.androidx.navigation)
implementation(libs.androidx.adaptive.navigation.android) implementation(libs.androidx.adaptive.navigation.android)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.bitfire.dav4jvm) { implementation(libs.bitfire.dav4jvm) {
@ -181,7 +180,6 @@ dependencies {
implementation(libs.androidx.hilt.navigation) implementation(libs.androidx.hilt.navigation)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.fragment.compose) implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.runtime)
@ -268,9 +266,6 @@ dependencies {
googleplayImplementation(libs.horologist.datalayer.grpc) googleplayImplementation(libs.horologist.datalayer.grpc)
googleplayImplementation(libs.horologist.datalayer.core) googleplayImplementation(libs.horologist.datalayer.core)
googleplayImplementation(libs.play.services.wearable) googleplayImplementation(libs.play.services.wearable)
googleplayImplementation(libs.microsoft.authentication) {
exclude("com.microsoft.device.display", "display-mask")
}
googleplayImplementation(projects.wearDatalayer) googleplayImplementation(projects.wearDatalayer)
androidTestImplementation(libs.dagger.hilt.testing) androidTestImplementation(libs.dagger.hilt.testing)

10
app/proguard.pro vendored

@ -26,8 +26,6 @@
-dontwarn net.fortuna.ical4j.model.** -dontwarn net.fortuna.ical4j.model.**
-dontwarn org.codehaus.groovy.** -dontwarn org.codehaus.groovy.**
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency -dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
-dontwarn com.github.erosb.jsonsKema.** # ical4android
-dontwarn org.jparsec.** # ical4android
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime) -keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
-keep class at.bitfire.** { *; } # all DAVdroid code is required -keep class at.bitfire.** { *; } # all DAVdroid code is required
@ -53,12 +51,4 @@
# material icons # material icons
-keep class androidx.compose.material.icons.outlined.** { *; } -keep class androidx.compose.material.icons.outlined.** { *; }
# microsoft authentication
-dontwarn com.microsoft.device.display.DisplayMask
-dontwarn com.google.android.libraries.identity.**
-dontwarn edu.umd.cs.findbugs.annotations.**
-dontwarn com.google.crypto.tink.subtle.**
-dontwarn net.jcip.annotations.**
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { <fields>; } -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { <fields>; }

@ -349,7 +349,7 @@ class DateUtilitiesTest {
} }
@Test @Test
fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("he")) { fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("iw")) {
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("he")) { fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("iw")) {
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, "account1") val deleted = googleTaskDao.getDeletedByTaskId(1)
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, "account1") val deleted = googleTaskDao.getDeletedByTaskId(2)
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, "account1").isEmpty()) assertTrue(googleTaskDao.getDeletedByTaskId(1).isEmpty())
assertEquals(1, googleTaskDao.getAllByTaskId(1).size.toLong()) assertEquals(1, googleTaskDao.getAllByTaskId(1).size.toLong())
} }

@ -5,29 +5,22 @@
*/ */
package com.todoroo.astrid.service package com.todoroo.astrid.service
import org.tasks.data.entity.Task
import com.todoroo.astrid.utility.TitleParser import com.todoroo.astrid.utility.TitleParser
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.fortuna.ical4j.model.Recur.Frequency.DAILY import net.fortuna.ical4j.model.Recur.Frequency.*
import net.fortuna.ical4j.model.Recur.Frequency.MONTHLY import org.junit.Assert.*
import net.fortuna.ical4j.model.Recur.Frequency.WEEKLY
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Ignore import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.tasks.R import org.tasks.R
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.TagDataDao import org.tasks.data.dao.TagDataDao
import org.tasks.data.entity.Task
import org.tasks.data.newLocalAccount
import org.tasks.date.DateTimeUtils import org.tasks.date.DateTimeUtils
import org.tasks.injection.InjectingTestCase import org.tasks.injection.InjectingTestCase
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.repeats.RecurrenceUtils.newRecur import org.tasks.repeats.RecurrenceUtils.newRecur
import java.util.Calendar import java.util.*
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@ -35,15 +28,11 @@ class TitleParserTest : InjectingTestCase() {
@Inject lateinit var tagDataDao: TagDataDao @Inject lateinit var tagDataDao: TagDataDao
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@Inject lateinit var taskCreator: TaskCreator @Inject lateinit var taskCreator: TaskCreator
@Inject lateinit var caldavDao: CaldavDao
@Before @Before
override fun setUp() { override fun setUp() {
runBlocking { super.setUp()
super.setUp() preferences.setStringFromInteger(R.string.p_default_urgency_key, 0)
preferences.setStringFromInteger(R.string.p_default_urgency_key, 0)
caldavDao.newLocalAccount()
}
} }
/** /**

@ -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_NEXTCLOUD, loadAccount().serverType) assertEquals(SERVER_OWNCLOUD, loadAccount().serverType)
} }
@Test @Test

@ -1,25 +0,0 @@
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())
}
}
}

@ -9,8 +9,8 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.CaldavDao.Companion.LOCAL
import org.tasks.data.dao.DeletionDao import org.tasks.data.dao.DeletionDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.CaldavTask
import org.tasks.date.DateTimeUtils.newDateTime import org.tasks.date.DateTimeUtils.newDateTime
@ -62,8 +62,7 @@ class DeletionDaoTests : InjectingTestCase() {
fun purgeDeletedLocalTask() = runBlocking { fun purgeDeletedLocalTask() = runBlocking {
val task = newTask(with(DELETION_TIME, newDateTime())) val task = newTask(with(DELETION_TIME, newDateTime()))
taskDao.createNew(task) taskDao.createNew(task)
caldavDao.insert(CaldavAccount(uuid = "abcd", accountType = CaldavAccount.TYPE_LOCAL)) caldavDao.insert(CaldavCalendar(name = "", uuid = "1234", account = LOCAL))
caldavDao.insert(CaldavCalendar(name = "", uuid = "1234", account = "abcd"))
caldavDao.insert(CaldavTask(task = task.id, calendar = "1234")) caldavDao.insert(CaldavTask(task = task.id, calendar = "1234"))
deletionDao.purgeDeleted() deletionDao.purgeDeleted()
@ -75,8 +74,7 @@ class DeletionDaoTests : InjectingTestCase() {
fun dontPurgeActiveTasks() = runBlocking { fun dontPurgeActiveTasks() = runBlocking {
val task = newTask() val task = newTask()
taskDao.createNew(task) taskDao.createNew(task)
caldavDao.insert(CaldavAccount(uuid = "abcd", accountType = CaldavAccount.TYPE_LOCAL)) caldavDao.insert(CaldavCalendar(name = "", uuid = "1234", account = LOCAL))
caldavDao.insert(CaldavCalendar(name = "", uuid = "1234", account = "abcd"))
caldavDao.insert(CaldavTask(task = task.id, calendar = "1234")) caldavDao.insert(CaldavTask(task = task.id, calendar = "1234"))
deletionDao.purgeDeleted() deletionDao.purgeDeleted()

@ -6,6 +6,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
class PermissivePermissionChecker(@ApplicationContext context: Context) : PermissionChecker(context) { class PermissivePermissionChecker(@ApplicationContext context: Context) : PermissionChecker(context) {
override fun canAccessCalendars() = true override fun canAccessCalendars() = true
override fun canAccessAccounts() = true
override fun canAccessForegroundLocation() = true override fun canAccessForegroundLocation() = true
override fun canAccessBackgroundLocation() = true override fun canAccessBackgroundLocation() = true

@ -12,16 +12,13 @@ import com.todoroo.astrid.timers.TimerPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.tasks.calendars.CalendarEventProvider import org.tasks.calendars.CalendarEventProvider
import org.tasks.data.dao.AlarmDao import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.LocationDao import org.tasks.data.dao.LocationDao
import org.tasks.data.dao.TagDataDao import org.tasks.data.dao.TagDataDao
import org.tasks.data.dao.UserActivityDao import org.tasks.data.dao.UserActivityDao
import org.tasks.data.db.Database import org.tasks.data.db.Database
import org.tasks.data.entity.Task import org.tasks.data.entity.Task
import org.tasks.data.newLocalAccount
import org.tasks.injection.InjectingTestCase import org.tasks.injection.InjectingTestCase
import org.tasks.location.GeofenceApi import org.tasks.location.GeofenceApi
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
@ -47,18 +44,9 @@ open class BaseTaskEditViewModelTest : InjectingTestCase() {
@Inject lateinit var tagDataDao: TagDataDao @Inject lateinit var tagDataDao: TagDataDao
@Inject lateinit var alarmDao: AlarmDao @Inject lateinit var alarmDao: AlarmDao
@Inject lateinit var userActivityDao: UserActivityDao @Inject lateinit var userActivityDao: UserActivityDao
@Inject lateinit var caldavDao: CaldavDao
protected lateinit var viewModel: TaskEditViewModel protected lateinit var viewModel: TaskEditViewModel
@Before
override fun setUp() {
runBlocking {
super.setUp()
caldavDao.newLocalAccount()
}
}
protected fun setup(task: Task) = runBlocking { protected fun setup(task: Task) = runBlocking {
viewModel = TaskEditViewModel( viewModel = TaskEditViewModel(
context, context,

@ -3,6 +3,7 @@ package org.tasks
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.VmPolicy import android.os.StrictMode.VmPolicy
import com.todoroo.andlib.utility.AndroidUtilities.atLeastOreo
import com.todoroo.andlib.utility.AndroidUtilities.atLeastQ import com.todoroo.andlib.utility.AndroidUtilities.atLeastQ
import leakcanary.AppWatcher import leakcanary.AppWatcher
import org.tasks.logging.FileLogger import org.tasks.logging.FileLogger
@ -36,7 +37,9 @@ class BuildSetup @Inject constructor(
.detectLeakedClosableObjects() .detectLeakedClosableObjects()
.detectFileUriExposure() .detectFileUriExposure()
.penaltyLog() .penaltyLog()
.detectContentUriWithoutPermission() if (atLeastOreo()) {
builder.detectContentUriWithoutPermission()
}
if (atLeastQ()) { if (atLeastQ()) {
builder builder
.detectCredentialProtectedWhileLocked() .detectCredentialProtectedWhileLocked()

@ -1,20 +0,0 @@
{
"client_id" : "9d4babd5-e7ba-4286-ba4b-17274495a901",
"authorization_user_agent" : "DEFAULT",
"redirect_uri" : "msauth://org.tasks/8wnYBRqh5nnQgFzbIXfxXSs41xE%3D",
"account_mode" : "MULTIPLE",
"authorities" : [
{
"type": "AAD",
"audience": {
"type": "AzureADandPersonalMicrosoftAccount",
"tenant_id": "common"
}
}
],
"logging": {
"level": "verbose",
"logcat_enabled": true,
"pii_enabled": true
}
}

@ -15,5 +15,4 @@
<string name="debug_force_restart">Restart app</string> <string name="debug_force_restart">Restart app</string>
<string name="debug_clear_hints">Clear hints</string> <string name="debug_clear_hints">Clear hints</string>
<string name="google_oauth_scheme">com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf</string> <string name="google_oauth_scheme">com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf</string>
<string name="microsoft_oauth_path">/8wnYBRqh5nnQgFzbIXfxXSs41xE=</string>
</resources> </resources>

@ -1,24 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools" <manifest>
xmlns:android="http://schemas.android.com/apk/res/android">
<application tools:ignore="MissingApplicationIcon"> <application/>
<activity
android:name=".auth.MicrosoftAuthenticationActivity"
android:theme="@style/TranslucentDialog"/>
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="${applicationId}"
android:scheme="msauth" />
</intent-filter>
</activity>
</application>
</manifest> </manifest>

@ -1,38 +0,0 @@
package org.tasks.auth
import android.net.Uri
import androidx.core.net.toUri
import net.openid.appauth.AuthorizationServiceConfiguration
import kotlin.coroutines.suspendCoroutine
data class IdentityProvider(
val name: String,
val discoveryEndpoint: Uri,
val clientId: String,
val redirectUri: Uri,
val scope: String
) {
suspend fun retrieveConfig(): AuthorizationServiceConfiguration {
return suspendCoroutine { cont ->
AuthorizationServiceConfiguration.fetchFromUrl(discoveryEndpoint) { serviceConfiguration, ex ->
cont.resumeWith(
when {
ex != null -> Result.failure(ex)
serviceConfiguration != null -> Result.success(serviceConfiguration)
else -> Result.failure(IllegalStateException())
}
)
}
}
}
companion object {
val MICROSOFT = IdentityProvider(
"Microsoft",
"https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration".toUri(),
"9d4babd5-e7ba-4286-ba4b-17274495a901",
"msauth://org.tasks/8wnYBRqh5nnQgFzbIXfxXSs41xE%3D".toUri(),
"user.read Tasks.ReadWrite openid offline_access email"
)
}
}

@ -1,29 +0,0 @@
package org.tasks.sync.microsoft
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.AuthState
import org.tasks.data.entity.CaldavAccount
import org.tasks.security.KeyStoreEncryption
import javax.inject.Inject
class MicrosoftTokenProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val encryption: KeyStoreEncryption,
) {
suspend fun getToken(account: CaldavAccount): String {
val authState = encryption.decrypt(account.password)?.let { AuthState.jsonDeserialize(it) }
?: throw RuntimeException("Missing credentials")
if (authState.needsTokenRefresh) {
val (token, ex) = context.requestTokenRefresh(authState)
authState.update(token, ex)
if (authState.isAuthorized) {
account.password = encryption.encrypt(authState.jsonSerializeString())
}
}
if (!authState.isAuthorized) {
throw RuntimeException("Needs authentication")
}
return authState.accessToken!!
}
}

@ -53,20 +53,6 @@
</intent-filter> </intent-filter>
</service> </service>
<activity
android:name="com.microsoft.identity.client.BrowserTabActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:path="@string/microsoft_oauth_path"
android:scheme="msauth" />
</intent-filter>
</activity>
</application> </application>
</manifest> </manifest>

@ -19,42 +19,12 @@ import javax.inject.Singleton
@Singleton @Singleton
class Firebase @Inject constructor( class Firebase @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext val context: Context,
private val preferences: Preferences private val preferences: Preferences
) { ) {
private val crashlytics by lazy { private var crashlytics: FirebaseCrashlytics? = null
if (preferences.isTrackingEnabled) { private var analytics: FirebaseAnalytics? = null
FirebaseCrashlytics.getInstance().apply { private var remoteConfig: FirebaseRemoteConfig? = null
setCrashlyticsCollectionEnabled(true)
}
} else {
null
}
}
private val analytics by lazy {
if (preferences.isTrackingEnabled) {
FirebaseAnalytics.getInstance(context).apply {
setAnalyticsCollectionEnabled(true)
}
} else {
null
}
}
private val remoteConfig by lazy {
if (preferences.isTrackingEnabled) {
FirebaseRemoteConfig.getInstance().apply {
setConfigSettingsAsync(remoteConfigSettings {
minimumFetchIntervalInSeconds =
TimeUnit.HOURS.toSeconds(WorkManager.REMOTE_CONFIG_INTERVAL_HOURS)
})
setDefaultsAsync(R.xml.remote_config_defaults)
}
} else {
null
}
}
fun reportException(t: Throwable) { fun reportException(t: Throwable) {
Timber.e(t) Timber.e(t)
@ -109,4 +79,22 @@ class Firebase @Inject constructor(
private fun days(key: String, default: Long): Long = private fun days(key: String, default: Long): Long =
TimeUnit.DAYS.toMillis(remoteConfig?.getLong(key) ?: default) TimeUnit.DAYS.toMillis(remoteConfig?.getLong(key) ?: default)
init {
if (preferences.isTrackingEnabled) {
analytics = FirebaseAnalytics.getInstance(context).apply {
setAnalyticsCollectionEnabled(true)
}
crashlytics = FirebaseCrashlytics.getInstance().apply {
setCrashlyticsCollectionEnabled(true)
}
remoteConfig = FirebaseRemoteConfig.getInstance().apply {
setConfigSettingsAsync(remoteConfigSettings {
minimumFetchIntervalInSeconds =
TimeUnit.HOURS.toSeconds(WorkManager.REMOTE_CONFIG_INTERVAL_HOURS)
})
setDefaultsAsync(R.xml.remote_config_defaults)
}
}
}
} }

@ -4,24 +4,27 @@ 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.ProductType import com.android.billingclient.api.BillingClient.SkuType
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.ProductDetailsParams import com.android.billingclient.api.BillingFlowParams.ProrationMode
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.QueryProductDetailsParams import com.android.billingclient.api.SkuDetailsParams
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
@ -46,61 +49,32 @@ class BillingClientImpl(
override suspend fun getSkus(skus: List<String>): List<Sku> = override suspend fun getSkus(skus: List<String>): List<Sku> =
executeServiceRequest { executeServiceRequest {
val productList = skus.map { val skuDetailsResult = withContext(Dispatchers.IO) {
QueryProductDetailsParams.Product.newBuilder() billingClient.querySkuDetails(
.setProductId(it) SkuDetailsParams
.setProductType(ProductType.SUBS) .newBuilder()
.build() .setType(SkuType.SUBS)
} .setSkusList(skus)
val params = QueryProductDetailsParams.newBuilder() .build()
.setProductList(productList) )
.build()
val productDetailsResult = withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.queryProductDetailsAsync(params) { 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 json = Json { ignoreUnknownKeys = true }
productDetailsResult.second?.map { productDetails -> skuDetailsResult
Sku( .skuDetailsList
productId = productDetails.productId, ?.map { json.decodeFromString<Sku>(it.originalJson) }
price = productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: emptyList()
?: 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 subsParams = QueryPurchasesParams.newBuilder() val subs = billingClient.queryPurchasesAsync(SkuType.SUBS)
.setProductType(ProductType.SUBS) val iaps = billingClient.queryPurchasesAsync(SkuType.INAPP)
.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()
@ -131,7 +105,7 @@ class BillingClientImpl(
purchases?.forEach { purchases?.forEach {
firebase.reportIabResult( firebase.reportIabResult(
result.responseCodeString, result.responseCodeString,
it.products.joinToString(","), it.skus.joinToString(","),
it.purchaseState.purchaseStateString it.purchaseState.purchaseStateString
) )
} }
@ -148,57 +122,31 @@ class BillingClientImpl(
oldPurchase: Purchase? oldPurchase: Purchase?
) { ) {
executeServiceRequest { executeServiceRequest {
val productList = listOf( val skuDetailsResult = withContext(Dispatchers.IO) {
QueryProductDetailsParams.Product.newBuilder() billingClient.querySkuDetails(
.setProductId(sku) SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType)
.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 =
val productDetails = productDetailsResult.second?.firstOrNull() skuDetailsResult
?: throw IllegalStateException("Product $sku not found") .skuDetailsList
?.firstOrNull()
val productDetailsParamsBuilder = ProductDetailsParams.newBuilder() ?: throw IllegalStateException("Sku $sku not found")
.setProductDetails(productDetails) val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
// 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()
.setOldPurchaseToken(it.purchaseToken) .setOldSkuPurchaseToken(it.purchaseToken)
.setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION) .setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.build() .build()
) )
} }
if (activity is OnPurchasesUpdated) { if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity onPurchasesUpdated = activity
} }
@ -266,28 +214,17 @@ 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(throwError = false) queryPurchases()
} }
} }
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 = ProductType.SUBS const val TYPE_SUBS = SkuType.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
@ -314,5 +251,11 @@ 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.products.first() get() = purchase.skus.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

@ -1,100 +0,0 @@
package org.tasks.sync.microsoft
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.microsoft.identity.client.AcquireTokenParameters
import com.microsoft.identity.client.AuthenticationCallback
import com.microsoft.identity.client.IAuthenticationResult
import com.microsoft.identity.client.Prompt
import com.microsoft.identity.client.PublicClientApplication
import com.microsoft.identity.client.exception.MsalException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.analytics.Constants
import org.tasks.analytics.Firebase
import org.tasks.data.UUIDHelper
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT
import org.tasks.extensions.Context.toast
import org.tasks.jobs.WorkManager
import org.tasks.sync.SyncAdapters
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class MicrosoftSignInViewModel @Inject constructor(
private val caldavDao: CaldavDao,
private val firebase: Firebase,
private val syncAdapters: SyncAdapters,
private val workManager: WorkManager,
) : ViewModel() {
fun signIn(activity: Activity) {
viewModelScope.launch(Dispatchers.IO) {
val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
activity,
R.raw.microsoft_config
)
val parameters = AcquireTokenParameters.Builder()
.startAuthorizationFromActivity(activity)
.withScopes(scopes)
.withPrompt(Prompt.SELECT_ACCOUNT)
.withCallback(object : AuthenticationCallback {
override fun onSuccess(authenticationResult: IAuthenticationResult) {
val email = authenticationResult.account.claims?.get("preferred_username") as? String
if (email == null) {
Timber.e("No email found")
return
}
Timber.d("Successfully signed in")
viewModelScope.launch {
caldavDao
.getAccount(TYPE_MICROSOFT, email)
?.let {
caldavDao.update(
it.copy(error = null)
)
}
?: caldavDao
.insert(
CaldavAccount(
uuid = UUIDHelper.newUUID(),
name = email,
username = email,
accountType = TYPE_MICROSOFT,
)
)
.also {
firebase.logEvent(
R.string.event_sync_add_account,
R.string.param_type to Constants.SYNC_TYPE_MICROSOFT
)
}
syncAdapters.sync(true)
workManager.updateBackgroundSync()
}
}
override fun onError(exception: MsalException?) {
Timber.e(exception)
activity.toast(exception?.message ?: exception?.javaClass?.simpleName ?: "Sign in failed")
}
override fun onCancel() {
Timber.d("onCancel")
}
})
.build()
app.acquireToken(parameters)
}
}
companion object {
val scopes = listOf("https://graph.microsoft.com/.default")
}
}

@ -1,39 +0,0 @@
package org.tasks.sync.microsoft
import android.content.Context
import com.microsoft.identity.client.AcquireTokenSilentParameters
import com.microsoft.identity.client.PublicClientApplication
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R
import org.tasks.data.entity.CaldavAccount
import timber.log.Timber
import javax.inject.Inject
class MicrosoftTokenProvider @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun getToken(account: CaldavAccount): String {
val app = PublicClientApplication.createMultipleAccountPublicClientApplication(
context,
R.raw.microsoft_config
)
val result = try {
val msalAccount = app.accounts.firstOrNull { it.username == account.username }
?: throw RuntimeException("No matching account found")
val parameters = AcquireTokenSilentParameters.Builder()
.withScopes(MicrosoftSignInViewModel.scopes)
.forAccount(msalAccount)
.fromAuthority(msalAccount.authority)
.forceRefresh(true)
.build()
app.acquireTokenSilent(parameters)
} catch (e: Exception) {
Timber.e(e)
throw RuntimeException("Authentication failed: ${e.message}")
}
return result.accessToken
}
}

@ -25,7 +25,7 @@ class WearRefresherImpl(
init { init {
phoneDataLayerAppHelper phoneDataLayerAppHelper
.connectedAndInstalledNodes .connectedAndInstalledNodes
.catch { Timber.e("${it.message}") } .catch { Timber.e(it) }
.onEach { nodes -> .onEach { nodes ->
Timber.d("Connected nodes: ${nodes.joinToString()}") Timber.d("Connected nodes: ${nodes.joinToString()}")
watchConnected = nodes.isNotEmpty() watchConnected = nodes.isNotEmpty()

@ -1,20 +0,0 @@
{
"client_id" : "9d4babd5-e7ba-4286-ba4b-17274495a901",
"authorization_user_agent" : "DEFAULT",
"redirect_uri" : "msauth://org.tasks/sEe08kX5nGJi4miFX3VkNXICC%2FY%3D",
"account_mode" : "MULTIPLE",
"authorities" : [
{
"type": "AAD",
"audience": {
"type": "AzureADandPersonalMicrosoftAccount",
"tenant_id": "common"
}
}
],
"logging": {
"level": "info",
"logcat_enabled": true,
"pii_enabled": false
}
}

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="google_oauth_scheme">com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif</string> <string name="google_oauth_scheme">com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif</string>
<string name="microsoft_oauth_path">/sEe08kX5nGJi4miFX3VkNXICC/Y=</string>
</resources> </resources>

@ -66,24 +66,13 @@
<!-- **************************************** --> <!-- **************************************** -->
<uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES"/> <uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES"/>
<uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH"/> <uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="25"/>
<!-- ****************************** --> <!-- ****************************** -->
<!-- Check DAVx5/EteSync sync state --> <!-- Check DAVx5/EteSync sync state -->
<!-- ****************************** --> <!-- ****************************** -->
<uses-permission android:name="android.permission.READ_SYNC_STATS" /> <uses-permission android:name="android.permission.READ_SYNC_STATS" />
<!-- ******************************** -->
<!-- Microsoft Authentication Library -->
<!-- ******************************** -->
<uses-permission android:name="android.permission.GET_ACCOUNTS" tools:node="remove" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<!-- ****************************************** --> <!-- ****************************************** -->
<!-- Exclude OpenTasks and jtxBoard permissions --> <!-- Exclude OpenTasks and jtxBoard permissions -->
<!-- ****************************************** --> <!-- ****************************************** -->
@ -159,13 +148,13 @@
</queries> </queries>
<application <application
android:pageSizeCompat="enabled"
android:allowBackup="true" android:allowBackup="true"
android:backupAgent="org.tasks.backup.TasksBackupAgent" android:backupAgent="org.tasks.backup.TasksBackupAgent"
android:backupInForeground="true" android:backupInForeground="true"
android:fullBackupOnly="false" android:fullBackupOnly="false"
android:icon="@mipmap/ic_launcher_blue" android:icon="@mipmap/ic_launcher_blue"
android:label="@string/app_name" android:label="@string/app_name"
android:manageSpaceActivity="org.tasks.preferences.ManageSpaceActivity"
android:name=".TasksApplication" android:name=".TasksApplication"
android:roundIcon="@mipmap/ic_launcher_blue" android:roundIcon="@mipmap/ic_launcher_blue"
android:supportsRtl="true" android:supportsRtl="true"
@ -191,6 +180,14 @@
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" /> <data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="${applicationId}"
android:scheme="msauth" />
</intent-filter>
</activity> </activity>
<activity <activity
@ -282,7 +279,6 @@
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
<data android:mimeType="audio/*" /> <data android:mimeType="audio/*" />
<data android:mimeType="video/*"/>
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
@ -387,13 +383,6 @@
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"/>
@ -439,10 +428,6 @@
android:name=".caldav.CaldavAccountSettingsActivity" android:name=".caldav.CaldavAccountSettingsActivity"
android:theme="@style/Tasks"/> android:theme="@style/Tasks"/>
<activity
android:name=".caldav.LocalAccountSettingsActivity"
android:theme="@style/Tasks" />
<activity <activity
android:name=".etebase.EtebaseAccountSettingsActivity" android:name=".etebase.EtebaseAccountSettingsActivity"
android:theme="@style/Tasks" /> android:theme="@style/Tasks" />
@ -635,11 +620,14 @@
<receiver android:name="org.tasks.jobs.NotificationReceiver" /> <receiver android:name="org.tasks.jobs.NotificationReceiver" />
<activity <activity
android:name="com.todoroo.astrid.activity.MainActivity" android:name=".auth.MicrosoftAuthenticationActivity"
android:exported="true" android:theme="@style/TranslucentDialog"/>
android:launchMode="singleTask"
android:theme="@style/Tasks" <activity
android:windowSoftInputMode="adjustResize"> android:launchMode="singleTask"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:name="com.todoroo.astrid.activity.MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
</intent-filter> </intent-filter>
@ -676,6 +664,9 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".preferences.ManageSpaceActivity"
android:theme="@style/Tasks" />
<activity android:name="org.tasks.sync.microsoft.MicrosoftListSettingsActivity" /> <activity android:name="org.tasks.sync.microsoft.MicrosoftListSettingsActivity" />
<activity <activity

@ -39,14 +39,32 @@ object AndroidUtilities {
return (dp * displayMetrics.density + 0.5f).toInt() return (dp * displayMetrics.density + 0.5f).toInt()
} }
fun preOreo(): Boolean {
return !atLeastOreo()
}
fun preS(): Boolean { fun preS(): Boolean {
return !atLeastS() return !atLeastS()
} }
@JvmStatic
fun preTiramisu(): Boolean {
return !atLeastTiramisu()
}
fun preUpsideDownCake(): Boolean { fun preUpsideDownCake(): Boolean {
return Build.VERSION.SDK_INT <= VERSION_CODES.TIRAMISU return Build.VERSION.SDK_INT <= VERSION_CODES.TIRAMISU
} }
fun atLeastNougatMR1(): Boolean {
return Build.VERSION.SDK_INT >= VERSION_CODES.N_MR1
}
@JvmStatic
fun atLeastOreo(): Boolean {
return Build.VERSION.SDK_INT >= VERSION_CODES.O
}
fun atLeastOreoMR1(): Boolean { fun atLeastOreoMR1(): Boolean {
return Build.VERSION.SDK_INT >= VERSION_CODES.O_MR1 return Build.VERSION.SDK_INT >= VERSION_CODES.O_MR1
} }
@ -75,14 +93,6 @@ object AndroidUtilities {
return Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU return Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU
} }
fun atLeastAndroid15(): Boolean {
return Build.VERSION.SDK_INT >= VERSION_CODES.VANILLA_ICE_CREAM
}
fun atLeastAndroid16(): Boolean {
return Build.VERSION.SDK_INT >= VERSION_CODES.BAKLAVA
}
fun assertMainThread() { fun assertMainThread() {
check(!(BuildConfig.DEBUG && !isMainThread)) { "Should be called from main thread" } check(!(BuildConfig.DEBUG && !isMainThread)) { "Should be called from main thread" }
} }

@ -12,12 +12,8 @@ import static java.util.Arrays.asList;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.ViewGroup;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -88,24 +84,12 @@ public class BeastModePreferences extends ThemedInjectingAppCompatActivity
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
BeastModePrefActivityBinding binding = BeastModePrefActivityBinding.inflate(getLayoutInflater()); BeastModePrefActivityBinding binding = BeastModePrefActivityBinding.inflate(getLayoutInflater());
Toolbar toolbar = binding.toolbar.toolbar; Toolbar toolbar = binding.toolbar.toolbar;
RecyclerView recyclerView = binding.recyclerView; RecyclerView recyclerView = binding.recyclerView;
setContentView(binding.getRoot()); setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(
binding.getRoot(),
(v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
ViewGroup.MarginLayoutParams toolbarParams =
(ViewGroup.MarginLayoutParams) toolbar.getLayoutParams();
toolbarParams.topMargin = systemBars.top;
recyclerView.setPadding(0, 0, 0, systemBars.bottom);
return insets;
});
toolbar.setNavigationIcon( toolbar.setNavigationIcon(
getDrawable(R.drawable.ic_outline_arrow_back_24px)); getDrawable(R.drawable.ic_outline_arrow_back_24px));
toolbar.setNavigationOnClickListener(v -> finish()); toolbar.setNavigationOnClickListener(v -> finish());

@ -11,75 +11,103 @@ import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat.getParcelableExtra import androidx.core.content.IntentCompat.getParcelableExtra
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.compose.AndroidFragment
import androidx.hilt.navigation.compose.hiltViewModel import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import com.todoroo.astrid.activity.TaskEditFragment.Companion.EXTRA_TASK
import androidx.navigation.compose.composable import com.todoroo.astrid.activity.TaskListFragment.Companion.EXTRA_FILTER
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.todoroo.astrid.adapter.SubheaderClickHandler import com.todoroo.astrid.adapter.SubheaderClickHandler
import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity
import com.todoroo.astrid.service.TaskCreator import com.todoroo.astrid.service.TaskCreator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.R import org.tasks.R
import org.tasks.TasksApplication
import org.tasks.activities.TagSettingsActivity
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.auth.SignInActivity
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
import org.tasks.caldav.CaldavAccountSettingsActivity import org.tasks.billing.PurchaseActivity
import org.tasks.compose.AddAccountDestination import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
import org.tasks.compose.HomeDestination import org.tasks.compose.drawer.DrawerAction
import org.tasks.compose.accounts.AddAccountScreen import org.tasks.compose.drawer.DrawerItem
import org.tasks.compose.accounts.AddAccountViewModel import org.tasks.compose.drawer.MenuSearchBar
import org.tasks.compose.home.HomeScreen import org.tasks.compose.drawer.TaskListDrawer
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.LocationDao import org.tasks.data.dao.LocationDao
import org.tasks.data.dao.TagDataDao import org.tasks.data.dao.TagDataDao
import org.tasks.data.entity.Place
import org.tasks.data.entity.Task import org.tasks.data.entity.Task
import org.tasks.dialogs.ImportTasksDialog import org.tasks.data.listSettingsClass
import org.tasks.dialogs.NewFilterDialog import org.tasks.dialogs.NewFilterDialog
import org.tasks.etebase.EtebaseAccountSettingsActivity
import org.tasks.extensions.Context.nightMode import org.tasks.extensions.Context.nightMode
import org.tasks.extensions.Context.toast import org.tasks.extensions.Context.openUri
import org.tasks.extensions.broughtToFront import org.tasks.extensions.broughtToFront
import org.tasks.extensions.flagsToString import org.tasks.extensions.flagsToString
import org.tasks.extensions.isFromHistory import org.tasks.extensions.isFromHistory
import org.tasks.files.FileHelper
import org.tasks.filters.Filter import org.tasks.filters.Filter
import org.tasks.jobs.WorkManager import org.tasks.filters.FilterProvider
import org.tasks.filters.FilterProvider.Companion.REQUEST_NEW_LIST
import org.tasks.filters.FilterProvider.Companion.REQUEST_NEW_PLACE
import org.tasks.filters.FilterProvider.Companion.REQUEST_NEW_TAGS
import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.filters.PlaceFilter
import org.tasks.kmp.org.tasks.compose.TouchSlopMultiplier
import org.tasks.kmp.org.tasks.compose.rememberImeState
import org.tasks.location.LocationPickerActivity
import org.tasks.location.LocationPickerActivity.Companion.EXTRA_PLACE
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.HelpAndFeedback
import org.tasks.preferences.MainPreferences
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.preferences.fragments.FRAG_TAG_IMPORT_TASKS
import org.tasks.sync.AddAccountDialog
import org.tasks.sync.SyncAdapters
import org.tasks.sync.microsoft.MicrosoftSignInViewModel
import org.tasks.themes.ColorProvider import org.tasks.themes.ColorProvider
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
import org.tasks.themes.Theme import org.tasks.themes.Theme
@ -101,20 +129,17 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var alarmDao: AlarmDao @Inject lateinit var alarmDao: AlarmDao
@Inject lateinit var firebase: Firebase @Inject lateinit var firebase: Firebase
@Inject lateinit var caldavDao: CaldavDao @Inject lateinit var caldavDao: CaldavDao
@Inject lateinit var syncAdapters: SyncAdapters
@Inject lateinit var workManager: WorkManager
private val viewModel: MainActivityViewModel by viewModels() private val viewModel: MainActivityViewModel by viewModels()
private var currentNightMode = 0 private var currentNightMode = 0
private var currentPro = false private var currentPro = false
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var isReady = false
/** @see android.app.Activity.onCreate
*/
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
theme.themeBase.set(this) theme.themeBase.set(this)
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !isReady }
currentNightMode = nightMode currentNightMode = nightMode
currentPro = inventory.hasPro currentPro = inventory.hasPro
@ -123,11 +148,10 @@ class MainActivity : AppCompatActivity() {
lightScrim = Color.TRANSPARENT, lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT darkScrim = Color.TRANSPARENT
), ),
navigationBarStyle = if (theme.themeBase.isDarkTheme(this)) { navigationBarStyle = SystemBarStyle.auto(
SystemBarStyle.dark(Color.TRANSPARENT) lightScrim = Color.TRANSPARENT,
} else { darkScrim = Color.TRANSPARENT
SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) )
}
) )
setContent { setContent {
@ -135,136 +159,184 @@ class MainActivity : AppCompatActivity() {
theme = theme.themeBase.index, theme = theme.themeBase.index,
primary = theme.themeColor.primaryColor, primary = theme.themeColor.primaryColor,
) { ) {
val navController = rememberNavController() val drawerState = rememberDrawerState(
val hasAccount = viewModel initialValue = DrawerValue.Closed,
.accountExists confirmStateChange = {
.collectAsStateWithLifecycle(null) viewModel.setDrawerState(it == DrawerValue.Open)
.value true
LaunchedEffect(hasAccount) { }
Timber.d("hasAccount=$hasAccount") )
if (hasAccount == false) { val state = viewModel.state.collectAsStateWithLifecycle().value
navController.navigate(AddAccountDestination(showImport = true)) val currentWindowInsets = WindowInsets.systemBars.asPaddingValues()
val windowInsets = remember { mutableStateOf(currentWindowInsets) }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(currentWindowInsets) {
Timber.d("insets: $currentWindowInsets")
if (currentWindowInsets.calculateTopPadding() != 0.dp || currentWindowInsets.calculateBottomPadding() != 0.dp) {
windowInsets.value = currentWindowInsets
} }
isReady = hasAccount != null
} }
NavHost( val navigator = rememberListDetailPaneScaffoldNavigator(
navController = navController, calculatePaneScaffoldDirective(
startDestination = HomeDestination, windowAdaptiveInfo = currentWindowAdaptiveInfo(),
) { ).copy(
composable<AddAccountDestination> { horizontalPartitionSpacerSize = 0.dp,
val route = it.toRoute<AddAccountDestination>() verticalPartitionSpacerSize = 0.dp,
LaunchedEffect(hasAccount) { )
if (route.showImport && hasAccount == true) { )
navController.popBackStack() val isListVisible =
} navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
} val isDetailVisible =
val addAccountViewModel: AddAccountViewModel = hiltViewModel() navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
val microsoftVM: MicrosoftSignInViewModel = hiltViewModel()
val syncLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
syncAdapters.sync(true)
workManager.updateBackgroundSync()
} else {
result.data
?.getStringExtra(GtasksLoginActivity.EXTRA_ERROR)
?.let { toast(it) }
}
}
val importBackupLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
ImportTasksDialog.newImportTasksDialog(uri)
.show(supportFragmentManager, FRAG_TAG_IMPORT_TASKS)
}
}
AddAccountScreen(
gettingStarted = route.showImport,
hasTasksAccount = inventory.hasTasksAccount,
hasPro = inventory.hasPro,
onBack = { navController.popBackStack() },
signIn = { platform ->
firebase.logEvent(R.string.event_onboarding_sync, R.string.param_selection to platform)
when (platform) {
AddAccountDialog.Platform.TASKS_ORG ->
syncLauncher.launch(
Intent(this@MainActivity, SignInActivity::class.java)
)
AddAccountDialog.Platform.GOOGLE_TASKS -> TouchSlopMultiplier {
syncLauncher.launch( ModalNavigationDrawer(
Intent(this@MainActivity, GtasksLoginActivity::class.java) drawerState = drawerState,
) gesturesEnabled = isListVisible,
drawerContent = {
ModalDrawerSheet(
drawerState = drawerState,
windowInsets = WindowInsets(0, 0, 0, 0),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
TaskListDrawer(
arrangement = if (state.menuQuery.isBlank()) Arrangement.Top else Arrangement.Bottom,
filters = if (state.menuQuery.isNotEmpty()) state.searchItems else state.drawerItems,
onClick = {
when (it) {
is DrawerItem.Filter -> {
viewModel.setFilter(it.filter)
scope.launch {
drawerState.close()
keyboard?.hide()
}
}
AddAccountDialog.Platform.MICROSOFT -> is DrawerItem.Header -> {
microsoftVM.signIn(this@MainActivity) viewModel.toggleCollapsed(it.header)
}
}
},
onAddClick = {
scope.launch {
drawerState.close()
when (it.header.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
NewFilterDialog.newFilterDialog().show(
supportFragmentManager,
SubheaderClickHandler.FRAG_TAG_NEW_FILTER
)
AddAccountDialog.Platform.CALDAV -> REQUEST_NEW_PLACE ->
syncLauncher.launch( startActivityForResult(
Intent(this@MainActivity, CaldavAccountSettingsActivity::class.java) Intent(
) this@MainActivity,
LocationPickerActivity::class.java
),
REQUEST_NEW_PLACE
)
AddAccountDialog.Platform.ETESYNC -> REQUEST_NEW_TAGS ->
syncLauncher.launch( startActivityForResult(
Intent(this@MainActivity, EtebaseAccountSettingsActivity::class.java) Intent(
) this@MainActivity,
TagSettingsActivity::class.java
),
REQUEST_NEW_LIST
)
AddAccountDialog.Platform.LOCAL -> REQUEST_NEW_LIST -> {
addAccountViewModel.createLocalAccount() val account =
caldavDao.getAccount(it.header.id.toLong())
?: return@launch
when (it.header.subheaderType) {
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS,
->
startActivityForResult(
Intent(
this@MainActivity,
account.listSettingsClass()
)
.putExtra(
EXTRA_CALDAV_ACCOUNT,
account
),
REQUEST_NEW_LIST
)
else -> throw IllegalArgumentException() else -> {}
} }
}, }
openUrl = { platform ->
firebase.logEvent(R.string.event_onboarding_sync, R.string.param_selection to platform.name) else -> Timber.e("Unhandled request code: $it")
addAccountViewModel.openUrl(this@MainActivity, platform) }
}, }
onImportBackup = { },
firebase.logEvent(R.string.event_onboarding_sync, R.string.param_selection to "import_backup") onErrorClick = {
importBackupLauncher.launch( context.startActivity(Intent(context, MainPreferences::class.java))
FileHelper.newFilePickerIntent(this@MainActivity, preferences.backupDirectory), },
searchBar = {
MenuSearchBar(
begForMoney = state.begForMoney,
onDrawerAction = {
scope.launch {
drawerState.close()
when (it) {
DrawerAction.PURCHASE ->
if (TasksApplication.IS_GENERIC)
context.openUri(R.string.url_donate)
else
context.startActivity(
Intent(
context,
PurchaseActivity::class.java
)
)
DrawerAction.HELP_AND_FEEDBACK ->
context.startActivity(
Intent(
context,
HelpAndFeedback::class.java
)
)
}
}
},
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
},
) )
} }
)
}
composable<HomeDestination> {
if (hasAccount != true) {
return@composable
} }
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val state = viewModel.state.collectAsStateWithLifecycle().value
val drawerState = rememberDrawerState(
initialValue = DrawerValue.Closed,
confirmStateChange = {
viewModel.setDrawerState(it == DrawerValue.Open)
true
}
)
val navigator = rememberListDetailPaneScaffoldNavigator(
calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(
windowAdaptiveInfo = currentWindowAdaptiveInfo(),
).copy(
horizontalPartitionSpacerSize = 0.dp,
verticalPartitionSpacerSize = 0.dp,
)
)
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(state.task) { LaunchedEffect(state.task) {
val pane = if (state.task == null) { if (state.task == null) {
ThreePaneScaffoldRole.Secondary if (intent.finishAffinity) {
finishAffinity()
} else {
if (intent.removeTask && intent.broughtToFront) {
moveTaskToBack(true)
}
keyboard?.hide()
navigator.navigateTo(pane = ThreePaneScaffoldRole.Secondary)
}
} else { } else {
ThreePaneScaffoldRole.Primary navigator.navigateTo(pane = ThreePaneScaffoldRole.Primary)
} }
Timber.d("Navigating to $pane")
navigator.navigateTo(pane = pane)
} }
val isDetailVisible =
navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
BackHandler(enabled = state.task == null) { BackHandler(enabled = state.task == null) {
Timber.d("onBackPressed") Timber.d("onBackPressed")
if (isDetailVisible && navigator.canNavigateBack()) { if (intent.finishAffinity) {
finishAffinity()
} else if (isDetailVisible && navigator.canNavigateBack()) {
scope.launch { scope.launch {
navigator.navigateBack() navigator.navigateBack()
} }
@ -285,15 +357,80 @@ class MainActivity : AppCompatActivity() {
} }
drawerState.close() drawerState.close()
} }
HomeScreen( ListDetailPaneScaffold(
state = state, directive = navigator.scaffoldDirective,
drawerState = drawerState, value = navigator.scaffoldValue,
navigator = navigator, listPane = {
showNewFilterDialog = { key (state.filter) {
NewFilterDialog.newFilterDialog().show( val fragment = remember { mutableStateOf<TaskListFragment?>(null) }
supportFragmentManager, val keyboardOpen = rememberImeState()
SubheaderClickHandler.FRAG_TAG_NEW_FILTER AndroidFragment<TaskListFragment>(
) fragmentState = rememberFragmentState(),
arguments = remember(state.filter) {
Bundle()
.apply { putParcelable(EXTRA_FILTER, state.filter) }
},
modifier = Modifier
.fillMaxSize()
.imePadding(),
) { tlf ->
fragment.value = tlf
tlf.applyInsets(windowInsets.value)
tlf.setNavigationClickListener {
scope.launch { drawerState.open() }
}
}
LaunchedEffect(fragment, windowInsets, keyboardOpen.value) {
fragment.value?.applyInsets(
if (keyboardOpen.value) {
PaddingValues(
top = windowInsets.value.calculateTopPadding(),
)
} else {
windowInsets.value
}
)
}
}
},
detailPane = {
val direction = LocalLayoutDirection.current
Box(
modifier = Modifier
.fillMaxSize()
.padding(
top = windowInsets.value.calculateTopPadding(),
start = windowInsets.value.calculateStartPadding(direction),
end = windowInsets.value.calculateEndPadding(direction),
bottom = if (rememberImeState().value)
WindowInsets.ime.asPaddingValues().calculateBottomPadding()
else
windowInsets.value.calculateBottomPadding()
),
contentAlignment = Alignment.Center,
) {
if (state.task == null) {
if (isListVisible && isDetailVisible) {
Icon(
painter = painterResource(org.tasks.kmp.R.drawable.ic_launcher_no_shadow_foreground),
contentDescription = null,
modifier = Modifier.size(192.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
key(state.task) {
AndroidFragment<TaskEditFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.task) {
Bundle()
.apply { putParcelable(EXTRA_TASK, state.task) }
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}, },
) )
} }
@ -304,6 +441,27 @@ class MainActivity : AppCompatActivity() {
handleIntent() handleIntent()
} }
@Deprecated("Deprecated in Java")
public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_NEW_LIST ->
if (resultCode == RESULT_OK && data != null) {
getParcelableExtra(data, OPEN_FILTER, Filter::class.java)?.let {
viewModel.setFilter(it)
}
}
REQUEST_NEW_PLACE ->
if (resultCode == RESULT_OK && data != null) {
getParcelableExtra(data, EXTRA_PLACE, Place::class.java)?.let {
viewModel.setFilter(PlaceFilter(it))
}
}
else ->
super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
@ -415,17 +573,23 @@ class MainActivity : AppCompatActivity() {
} }
val Intent.removeTask: Boolean val Intent.removeTask: Boolean
get() = try { get() = if (isFromHistory) {
getBooleanExtra(REMOVE_TASK, false) && !isFromHistory && !broughtToFront false
} finally { } else {
removeExtra(REMOVE_TASK) getBooleanExtra(REMOVE_TASK, false).let {
removeExtra(REMOVE_TASK)
it
}
} }
val Intent.finishAffinity: Boolean val Intent.finishAffinity: Boolean
get() = try { get() = if (isFromHistory) {
getBooleanExtra(FINISH_AFFINITY, false) && !isFromHistory && !broughtToFront false
} finally { } else {
removeExtra(FINISH_AFFINITY) getBooleanExtra(FINISH_AFFINITY, false).let {
removeExtra(FINISH_AFFINITY)
it
}
} }
} }
} }

@ -14,7 +14,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -39,7 +38,6 @@ import org.tasks.filters.CaldavFilter
import org.tasks.filters.Filter import org.tasks.filters.Filter
import org.tasks.filters.FilterProvider import org.tasks.filters.FilterProvider
import org.tasks.filters.NavigationDrawerSubheader import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.filters.SearchFilter
import org.tasks.filters.getIcon import org.tasks.filters.getIcon
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.TasksPreferences import org.tasks.preferences.TasksPreferences
@ -85,13 +83,11 @@ class MainActivityViewModel @Inject constructor(
) )
val state = _state.asStateFlow() val state = _state.asStateFlow()
val accountExists: Flow<Boolean>
get() = caldavDao.watchAccountExists()
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 -> _updateFilters.update { currentTimeMillis() } LocalBroadcastManager.REFRESH,
LocalBroadcastManager.REFRESH_LIST -> _updateFilters.update { currentTimeMillis() }
} }
} }
} }
@ -114,9 +110,12 @@ class MainActivityViewModel @Inject constructor(
) )
} }
updateFilters() updateFilters()
if (filter !is SearchFilter) { defaultFilterProvider.setLastViewedFilter(filter)
defaultFilterProvider.setLastViewedFilter(filter) }
}
fun closeDrawer() {
_drawerOpen.update { false }
_state.update { it.copy(menuQuery = "") }
} }
fun setDrawerState(opened: Boolean) { fun setDrawerState(opened: Boolean) {
@ -215,12 +214,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.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
NavigationDrawerSubheader.SubheaderType.CALDAV, NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS -> { NavigationDrawerSubheader.SubheaderType.TASKS -> {
caldavDao.setCollapsed(subheader.id, collapsed) caldavDao.setCollapsed(subheader.id, collapsed)
localBroadcastManager.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
} }
} }
@ -233,10 +232,4 @@ class MainActivityViewModel @Inject constructor(
_state.update { it.copy(menuQuery = query) } _state.update { it.copy(menuQuery = query) }
updateFilters() updateFilters()
} }
suspend fun getAccount(id: Long) = caldavDao.getAccount(id)
fun openLastViewedFilter() = viewModelScope.launch {
setFilter(defaultFilterProvider.getLastViewedFilter())
}
} }

@ -110,7 +110,7 @@ class ShareLinkActivity : AppCompatActivity() {
intent.type?.let { type -> ATTACHMENT_TYPES.any { type.startsWith(it) } } ?: false intent.type?.let { type -> ATTACHMENT_TYPES.any { type.startsWith(it) } } ?: false
companion object { companion object {
private val ATTACHMENT_TYPES = listOf("image/", "application/", "audio/", "video/", "text/plain") private val ATTACHMENT_TYPES = listOf("image/", "application/", "audio/")
private suspend fun TaskCreator.create(intent: Intent): Task { private suspend fun TaskCreator.create(intent: Intent): Task {
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)

@ -15,8 +15,6 @@ import androidx.fragment.app.viewModels
import androidx.fragment.compose.content import androidx.fragment.compose.content
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.todoroo.astrid.activity.MainActivity.Companion.finishAffinity
import com.todoroo.astrid.activity.MainActivity.Companion.removeTask
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.R import org.tasks.R
@ -26,7 +24,6 @@ import org.tasks.data.dao.UserActivityDao
import org.tasks.dialogs.DateTimePicker import org.tasks.dialogs.DateTimePicker
import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.DialogBuilder
import org.tasks.dialogs.Linkify import org.tasks.dialogs.Linkify
import org.tasks.extensions.hideKeyboard
import org.tasks.markdown.MarkdownProvider import org.tasks.markdown.MarkdownProvider
import org.tasks.notifications.NotificationManager import org.tasks.notifications.NotificationManager
import org.tasks.play.PlayServices import org.tasks.play.PlayServices
@ -35,7 +32,6 @@ import org.tasks.themes.TasksTheme
import org.tasks.themes.Theme import org.tasks.themes.Theme
import org.tasks.ui.ChipProvider import org.tasks.ui.ChipProvider
import org.tasks.ui.TaskEditViewModel import org.tasks.ui.TaskEditViewModel
import timber.log.Timber
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ -102,7 +98,7 @@ class TaskEditFragment : Fragment() {
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch { lifecycleScope.launch {
editViewModel.delete() editViewModel.delete()
clearTask() mainViewModel.setTask(null)
} }
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
@ -136,36 +132,17 @@ class TaskEditFragment : Fragment() {
} }
} }
private fun clearTask() {
Timber.d("clearTask()")
mainViewModel.setTask(null)
activity?.let { activity ->
activity.hideKeyboard()
when {
activity.intent.finishAffinity -> {
Timber.d("finishAffinity")
activity.finishAffinity()
}
activity.intent.removeTask -> {
Timber.d("removeTask")
activity.moveTaskToBack(true)
activity.finish()
}
}
}
}
suspend fun save(remove: Boolean = true) { suspend fun save(remove: Boolean = true) {
editViewModel.save() editViewModel.save()
if (remove) { if (remove) {
clearTask() mainViewModel.setTask(null)
} }
activity?.let { playServices.requestReview(it) } activity?.let { playServices.requestReview(it) }
} }
private fun discard() = lifecycleScope.launch { private fun discard() = lifecycleScope.launch {
editViewModel.discard() editViewModel.discard()
clearTask() mainViewModel.setTask(null)
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

@ -22,7 +22,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LAYOUT_DIRECTION_LTR import android.view.ViewGroup.LAYOUT_DIRECTION_LTR
import androidx.activity.OnBackPressedCallback import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -128,7 +128,6 @@ import org.tasks.filters.Filter
import org.tasks.filters.FilterImpl import org.tasks.filters.FilterImpl
import org.tasks.filters.MyTasksFilter import org.tasks.filters.MyTasksFilter
import org.tasks.filters.PlaceFilter import org.tasks.filters.PlaceFilter
import org.tasks.filters.SearchFilter
import org.tasks.filters.TagFilter import org.tasks.filters.TagFilter
import org.tasks.kmp.org.tasks.time.DateStyle import org.tasks.kmp.org.tasks.time.DateStyle
import org.tasks.kmp.org.tasks.time.getRelativeDateTime import org.tasks.kmp.org.tasks.time.getRelativeDateTime
@ -279,23 +278,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
taskListEventBus taskListEventBus
.onEach(this::process) .onEach(this::process)
.launchIn(viewLifecycleOwner.lifecycleScope) .launchIn(viewLifecycleOwner.lifecycleScope)
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if ((mainViewModel.state.value.filter as? SearchFilter)?.query?.isNotBlank() == true) {
lifecycleScope.launch {
mainViewModel.openLastViewedFilter()
}
if (search.isActionViewExpanded) {
search.collapseActionView()
}
Timber.d("Filtro resettato")
} else {
isEnabled = false // Disabilita il callback per consentire il comportamento predefinito
requireActivity().onBackPressedDispatcher.onBackPressed()
}
}
})
} }
fun setNavigationClickListener(onClick: () -> Unit) { fun setNavigationClickListener(onClick: () -> Unit) {
@ -337,9 +319,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
right = endInset, right = endInset,
) )
binding.bottomAppBar.updatePadding(bottom = bottomInset) binding.bottomAppBar.updatePadding(bottom = bottomInset)
val scrimLayoutParams = binding.systemBarScrim.layoutParams (binding.fab.layoutParams as MarginLayoutParams).bottomMargin = bottomInset / 2
scrimLayoutParams.height = bottomInset
binding.systemBarScrim.layoutParams = scrimLayoutParams
} }
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@ -366,14 +346,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
listViewModel.setFilter(filter) listViewModel.setFilter(filter)
(recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false (recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false
recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.layoutManager = LinearLayoutManager(context)
val baseFooterHeight = resources.getDimensionPixelSize(R.dimen.task_list_footer_height)
val additionalFabSpace = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
56f,
resources.displayMetrics
).toInt()
recyclerView.updatePadding(bottom = baseFooterHeight + additionalFabSpace)
lifecycleScope.launch { lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
listViewModel.updateBannerState() listViewModel.updateBannerState()
@ -403,11 +375,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
binding.bottomAppBar.performShow() binding.bottomAppBar.performShow()
} }
} }
val typedValue = TypedValue()
requireContext().theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)
val scrimColor = typedValue.data
binding.systemBarScrim.setBackgroundColor((scrimColor and 0x00FFFFFF) or 0xCC000000.toInt()) // 80% opacity
with (binding.fab) { with (binding.fab) {
backgroundTintList = ColorStateList.valueOf(themeColor.primaryColor) backgroundTintList = ColorStateList.valueOf(themeColor.primaryColor)
imageTintList = ColorStateList.valueOf(themeColor.colorOnPrimary) imageTintList = ColorStateList.valueOf(themeColor.colorOnPrimary)
@ -686,7 +653,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
} else { } else {
dialogBuilder dialogBuilder
.newDialog(R.string.clear_completed_tasks_confirmation) .newDialog(R.string.clear_completed_tasks_confirmation)
.setMessage(R.string.delete_tasks_warning, countString) .setMessage(R.string.clear_completed_tasks_count, countString)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch { lifecycleScope.launch {
listViewModel.markDeleted(tasks) listViewModel.markDeleted(tasks)
@ -782,12 +749,10 @@ 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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
taskMover: TaskMover, taskMover: TaskMover,
) : TaskAdapter(false, googleTaskDao, caldavDao, taskDao, refreshBroadcaster, taskMover) { ) : TaskAdapter(false, googleTaskDao, caldavDao, taskDao, localBroadcastManager, 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)
} }
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
): 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)
} }
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
} }

@ -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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
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)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefresh()
} }
private suspend fun changeCaldavParent(task: TaskContainer, indent: Int, to: Int): Long { private suspend fun changeCaldavParent(task: TaskContainer, indent: Int, to: Int): Long {

@ -75,16 +75,10 @@ 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(
baseline.plus((reminderPeriod * multiplier).toLong()), task.reminderLast
.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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
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) {
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
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()
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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()) {
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefresh()
} }
workManager.triggerNotifications() workManager.triggerNotifications()
workManager.scheduleRefresh() workManager.scheduleRefresh()

@ -14,9 +14,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.R import org.tasks.R
import org.tasks.Strings import org.tasks.Strings
import org.tasks.compose.edit.AttachmentRow import org.tasks.compose.edit.AttachmentRow
@ -110,16 +108,16 @@ class FilesControlSet : TaskEditControlFragment() {
) )
} }
private suspend fun newAttachment(output: Uri) { private fun newAttachment(output: Uri) {
val attachment = TaskAttachment( val attachment = TaskAttachment(
uri = output.toString(), uri = output.toString(),
name = FileHelper.getFilename(requireContext(), output)!!, name = FileHelper.getFilename(requireContext(), output)!!,
) )
withContext(Dispatchers.IO) { lifecycleScope.launch {
taskAttachmentDao.insert(attachment) taskAttachmentDao.insert(attachment)
viewModel.setAttachments( viewModel.setAttachments(
viewModel.viewState.value.attachments + viewModel.viewState.value.attachments +
(taskAttachmentDao.getAttachment(attachment.remoteId) ?: return@withContext)) (taskAttachmentDao.getAttachment(attachment.remoteId) ?: return@launch))
} }
} }

@ -11,7 +11,6 @@ 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
@ -20,7 +19,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.DateTime import org.tasks.time.DateTimeUtils2.currentTimeMillis
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
@ -31,8 +30,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) =
@ -110,7 +109,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.toUri(), updateValues, null, null) cr.update(Uri.parse(uri), 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)
} }
@ -118,10 +117,10 @@ class GCalHelper @Inject constructor(
suspend fun rescheduleRepeatingTask(task: Task) { suspend fun rescheduleRepeatingTask(task: Task) {
val taskUri = getTaskEventUri(task) val taskUri = getTaskEventUri(task)
if (taskUri.isNullOrBlank()) { if (isNullOrEmpty(taskUri)) {
return return
} }
val eventUri = taskUri.toUri() val eventUri = Uri.parse(taskUri)
val event = calendarEventProvider.getEvent(eventUri) val event = calendarEventProvider.getEvent(eventUri)
if (event == null) { if (event == null) {
task.calendarURI = "" task.calendarURI = ""
@ -135,6 +134,11 @@ 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()
@ -148,19 +152,24 @@ 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 {
val utcMidnight = DateTime(dueDate).toUTC().startOfDay() values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDate)
values.put(CalendarContract.Events.DTSTART, utcMidnight.millis) values.put(CalendarContract.Events.DTEND, tzCorrectedDueDate)
values.put(CalendarContract.Events.DTEND, utcMidnight.plusDays(1).millis)
values.put(CalendarContract.Events.ALL_DAY, "1") values.put(CalendarContract.Events.ALL_DAY, "1")
values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC)
} }
} else { } else {
Timber.w("Not creating calendar event, task has no due date: %s", task) values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDateNow)
values.put(CalendarContract.Events.DTEND, tzCorrectedDueDateNow)
values.put(CalendarContract.Events.ALL_DAY, "1")
}
if ("1" == values[CalendarContract.Events.ALL_DAY]) {
values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC)
} else {
values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
} }
} }
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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
) { ) {
/** /**
@ -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)!!)
} }
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
} }

@ -7,7 +7,6 @@ import com.google.api.services.tasks.model.Task
import com.google.api.services.tasks.model.TaskList import com.google.api.services.tasks.model.TaskList
import com.google.api.services.tasks.model.TaskLists import com.google.api.services.tasks.model.TaskLists
import org.tasks.googleapis.BaseInvoker import org.tasks.googleapis.BaseInvoker
import timber.log.Timber
import java.io.IOException import java.io.IOException
/** /**
@ -44,30 +43,21 @@ class GtasksInvoker(
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getAllPositions( suspend fun getAllPositions(
listId: String?, listId: String?, pageToken: String?): com.google.api.services.tasks.model.Tasks? =
pageToken: String?, execute(
): com.google.api.services.tasks.model.Tasks? = service!!
execute( .tasks()
service!! .list(listId)
.tasks() .setMaxResults(100)
.list(listId) .setShowDeleted(false)
.setMaxResults(100) .setShowHidden(false)
.setShowDeleted(false) .setPageToken(pageToken)
.setShowHidden(false) .setFields("items(id,parent,position),nextPageToken"))
.setPageToken(pageToken)
.setFields("items(id,parent,position),nextPageToken")
)
@Throws(IOException::class) @Throws(IOException::class)
suspend fun createGtask( suspend fun createGtask(
listId: String?, listId: String?, task: Task?, parent: String?, previous: String?): Task? =
task: Task?, execute(service!!.tasks().insert(listId, task).setParent(parent).setPrevious(previous))
parent: String?,
previous: String?,
): Task? {
Timber.d("createGtask(listId=$listId, task=<redacted>, parent=$parent, previous=$previous)")
return execute(service!!.tasks().insert(listId, task).setParent(parent).setPrevious(previous))
}
@Throws(IOException::class) @Throws(IOException::class)
suspend fun updateGtask(listId: String?, task: Task) = suspend fun updateGtask(listId: String?, task: Task) =
@ -75,26 +65,19 @@ class GtasksInvoker(
@Throws(IOException::class) @Throws(IOException::class)
suspend fun moveGtask( suspend fun moveGtask(
listId: String?, listId: String?, taskId: String?, parentId: String?, previousId: String?): Task? =
taskId: String?, execute(
parentId: String?, service!!
previousId: String?, .tasks()
): Task? { .move(listId, taskId)
Timber.d("moveGtask(listId=$listId, taskId=$taskId, parentId=$parentId, previousId=$previousId)") .setParent(parentId)
return execute( .setPrevious(previousId))
service!!
.tasks()
.move(listId, taskId)
.setParent(parentId)
.setPrevious(previousId)
)
}
@Throws(IOException::class) @Throws(IOException::class)
suspend fun deleteGtaskList(listId: String?) { suspend fun deleteGtaskList(listId: String?) {
try { try {
execute(service!!.tasklists().delete(listId)) execute(service!!.tasklists().delete(listId))
} catch (_: HttpNotFoundException) { } catch (ignored: HttpNotFoundException) {
} }
} }
@ -108,10 +91,9 @@ class GtasksInvoker(
@Throws(IOException::class) @Throws(IOException::class)
suspend fun deleteGtask(listId: String?, taskId: String?) { suspend fun deleteGtask(listId: String?, taskId: String?) {
Timber.d("deleteGtask(listId=$listId, taskId=$taskId)")
try { try {
execute(service!!.tasks().delete(listId, taskId)) execute(service!!.tasks().delete(listId, taskId))
} catch (_: HttpNotFoundException) { } catch (ignored: HttpNotFoundException) {
} }
} }
} }

@ -26,6 +26,7 @@ import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.TYPE_GOOGLE_TASKS import org.tasks.data.entity.CaldavAccount.Companion.TYPE_GOOGLE_TASKS
import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.DialogBuilder
import org.tasks.gtasks.GoogleAccountManager import org.tasks.gtasks.GoogleAccountManager
import org.tasks.preferences.ActivityPermissionRequestor
import org.tasks.preferences.PermissionRequestor import org.tasks.preferences.PermissionRequestor
import javax.inject.Inject import javax.inject.Inject
@ -40,11 +41,14 @@ class GtasksLoginActivity : AppCompatActivity() {
@Inject lateinit var dialogBuilder: DialogBuilder @Inject lateinit var dialogBuilder: DialogBuilder
@Inject lateinit var googleAccountManager: GoogleAccountManager @Inject lateinit var googleAccountManager: GoogleAccountManager
@Inject lateinit var caldavDao: CaldavDao @Inject lateinit var caldavDao: CaldavDao
@Inject lateinit var permissionRequestor: ActivityPermissionRequestor
@Inject lateinit var firebase: Firebase @Inject lateinit var firebase: Firebase
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
chooseAccount() if (permissionRequestor.requestAccountPermissions()) {
chooseAccount()
}
} }
private fun chooseAccount() { private fun chooseAccount() {

@ -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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
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()
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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) }
) )
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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) }
) )
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
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) }
) )
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
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 { refreshBroadcaster.broadcastRefresh() } .also { localBroadcastManager.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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
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)
} }
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefresh()
syncAdapters.sync() syncAdapters.sync()
} }

@ -3,9 +3,6 @@ package com.todoroo.astrid.service
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.google.common.collect.ImmutableListMultimap import com.google.common.collect.ImmutableListMultimap
import com.google.common.collect.ListMultimap import com.google.common.collect.ListMultimap
import com.google.common.collect.Multimaps import com.google.common.collect.Multimaps
@ -37,8 +34,6 @@ import org.tasks.data.entity.Filter
import org.tasks.data.entity.Tag import org.tasks.data.entity.Tag
import org.tasks.data.entity.TagData import org.tasks.data.entity.TagData
import org.tasks.filters.CaldavFilter import org.tasks.filters.CaldavFilter
import org.tasks.jobs.UpgradeIconSyncWork
import org.tasks.jobs.networkConstraints
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.time.DateTimeUtils2.currentTimeMillis import org.tasks.time.DateTimeUtils2.currentTimeMillis
@ -150,15 +145,6 @@ class Upgrader @Inject constructor(
} }
} }
} }
run(from, V14_8) {
WorkManager.getInstance(context).enqueueUniqueWork(
uniqueWorkName = "upload_icons",
existingWorkPolicy = ExistingWorkPolicy.KEEP,
request = OneTimeWorkRequestBuilder<UpgradeIconSyncWork>()
.setConstraints(networkConstraints)
.build()
)
}
preferences.setBoolean(R.string.p_just_updated, true) preferences.setBoolean(R.string.p_just_updated, true)
} else { } else {
setInstallDetails(to) setInstallDetails(to)
@ -421,7 +407,6 @@ class Upgrader @Inject constructor(
const val V12_8 = 120800 const val V12_8 = 120800
const val V14_5_4 = 140516 const val V14_5_4 = 140516
const val V14_6_1 = 140602 const val V14_6_1 = 140602
const val V14_8 = 140800
@JvmStatic @JvmStatic
fun getAndroidColor(context: Context, index: Int): Int { fun getAndroidColor(context: Context, index: Int): Int {

@ -73,11 +73,10 @@ class StartDateControlSet : TaskEditControlFragment() {
REQUEST_START_DATE, REQUEST_START_DATE,
vm.selectedDay.value, vm.selectedDay.value,
vm.selectedTime.value, vm.selectedTime.value,
autoClose = preferences.getBoolean( preferences.getBoolean(
R.string.p_auto_dismiss_datetime_edit_screen, R.string.p_auto_dismiss_datetime_edit_screen,
false false
), )
showDueDate = !viewModel.viewState.value.list.account.isOpenTasks,
) )
.show(fragmentManager, FRAG_TAG_DATE_PICKER) .show(fragmentManager, FRAG_TAG_DATE_PICKER)
} }

@ -7,14 +7,13 @@ 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?) {
@ -24,6 +23,7 @@ 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,11 +42,15 @@ class LocalBroadcastManager @Inject constructor(
) )
} }
override fun broadcastRefresh() { 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))
} }
@ -76,6 +80,7 @@ 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"

@ -2,16 +2,23 @@ package org.tasks
import android.content.Context import android.content.Context
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import com.todoroo.andlib.utility.AndroidUtilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ShortcutManager @Inject constructor(@ApplicationContext context: Context) { class ShortcutManager @Inject constructor(@ApplicationContext context: Context) {
private val shortcutManager = context.getSystemService(ShortcutManager::class.java) private val shortcutManager: ShortcutManager? = if (AndroidUtilities.atLeastNougatMR1()) {
context.getSystemService(ShortcutManager::class.java)
} else {
null
}
fun reportShortcutUsed(shortcutId: String) { fun reportShortcutUsed(shortcutId: String) {
shortcutManager?.reportShortcutUsed(shortcutId) if (AndroidUtilities.atLeastNougatMR1()) {
shortcutManager?.reportShortcutUsed(shortcutId)
}
} }
companion object { companion object {

@ -6,7 +6,6 @@ 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
@ -18,7 +17,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.Configuration import androidx.work.Configuration
import com.mikepenz.iconics.Iconics import com.mikepenz.iconics.Iconics
import com.todoroo.andlib.utility.AndroidUtilities.atLeastAndroid15
import com.todoroo.andlib.utility.AndroidUtilities.atLeastR import com.todoroo.andlib.utility.AndroidUtilities.atLeastR
import com.todoroo.astrid.service.Upgrader import com.todoroo.astrid.service.Upgrader
import dagger.Lazy import dagger.Lazy
@ -104,17 +102,11 @@ class TasksApplication : Application(), Configuration.Provider {
Timber.i("Astrid Startup. %s => %s", lastVersion, currentVersion) Timber.i("Astrid Startup. %s => %s", lastVersion, currentVersion)
if (atLeastR()) { if (atLeastR()) {
scope.launch { scope.launch {
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1) val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1)
logExitReasons(exitReasons) logExitReasons(exitReasons)
} }
} }
if (atLeastAndroid15()) {
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
activityManager.addApplicationStartInfoCompletionListener(mainExecutor) { startInfo ->
Timber.d("Application was force stopped: ${startInfo.wasForceStopped()}")
}
}
// invoke upgrade service // invoke upgrade service
if (lastVersion != currentVersion) { if (lastVersion != currentVersion) {

@ -8,13 +8,13 @@ import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
@ -26,24 +26,25 @@ import androidx.lifecycle.lifecycleScope
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import com.todoroo.andlib.utility.AndroidUtilities.atLeastS import com.todoroo.andlib.utility.AndroidUtilities
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.PurchaseActivity
import org.tasks.compose.DeleteButton import org.tasks.compose.DeleteButton
import org.tasks.compose.IconPickerActivity.Companion.launchIconPicker import org.tasks.compose.IconPickerActivity.Companion.launchIconPicker
import org.tasks.compose.IconPickerActivity.Companion.registerForIconPickerResult import org.tasks.compose.IconPickerActivity.Companion.registerForIconPickerResult
import org.tasks.compose.settings.ListSettingsContent import org.tasks.compose.settings.ListSettingsContent
import org.tasks.compose.settings.ListSettingsScaffold import org.tasks.compose.settings.ListSettingsScaffold
import org.tasks.data.UUIDHelper import org.tasks.data.UUIDHelper
import org.tasks.dialogs.ColorPalettePicker
import org.tasks.dialogs.ColorPalettePicker.Companion.newColorPalette
import org.tasks.dialogs.ColorPickerAdapter.Palette
import org.tasks.dialogs.ColorWheelPicker
import org.tasks.extensions.addBackPressedCallback import org.tasks.extensions.addBackPressedCallback
import org.tasks.filters.Filter import org.tasks.filters.Filter
import org.tasks.icons.OutlinedGoogleMaterial import org.tasks.icons.OutlinedGoogleMaterial
import org.tasks.intents.TaskIntents import org.tasks.intents.TaskIntents
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
import org.tasks.themes.ColorProvider
import org.tasks.themes.Theme import org.tasks.themes.Theme
import org.tasks.themes.contentColorFor import org.tasks.themes.contentColorFor
import org.tasks.widget.RequestPinWidgetReceiver import org.tasks.widget.RequestPinWidgetReceiver
@ -53,12 +54,10 @@ import org.tasks.widget.TasksWidget
import javax.inject.Inject import javax.inject.Inject
abstract class BaseListSettingsActivity : AppCompatActivity() { abstract class BaseListSettingsActivity : AppCompatActivity(), ColorPalettePicker.ColorPickedCallback, ColorWheelPicker.ColorPickedCallback {
@Inject lateinit var tasksTheme: Theme @Inject lateinit var tasksTheme: Theme
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider @Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var firebase: Firebase @Inject lateinit var firebase: Firebase
@Inject lateinit var inventory: Inventory
@Inject lateinit var colorProvider: ColorProvider
protected val baseViewModel: BaseListSettingsViewModel by viewModels() protected val baseViewModel: BaseListSettingsViewModel by viewModels()
@ -90,6 +89,11 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
} }
} }
private fun showThemePicker() {
newColorPalette(null, 0, baseViewModel.color, Palette.COLORS)
.show(supportFragmentManager, FRAG_TAG_COLOR_PICKER)
}
private val launcher = registerForIconPickerResult { selected -> private val launcher = registerForIconPickerResult { selected ->
baseViewModel.setIcon(selected) baseViewModel.setIcon(selected)
} }
@ -98,6 +102,10 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
launcher.launchIconPicker(this, baseViewModel.icon) launcher.launchIconPicker(this, baseViewModel.icon)
} }
override fun onColorPicked(color: Int) {
baseViewModel.setColor(color)
}
protected open fun promptDelete() { baseViewModel.promptDelete(true) } protected open fun promptDelete() { baseViewModel.promptDelete(true) }
/** Standard @Compose view content for descendants. Caller must wrap it to TasksTheme{} */ /** Standard @Compose view content for descendants. Caller must wrap it to TasksTheme{} */
@ -125,9 +133,7 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
fab = fab, fab = fab,
) { ) {
ListSettingsContent( ListSettingsContent(
hasPro = remember { inventory.purchasedThemes() },
color = viewState.color, color = viewState.color,
colors = remember { colorProvider.getThemeColors() },
icon = viewState.icon ?: defaultIcon, icon = viewState.icon ?: defaultIcon,
text = viewState.title, text = viewState.title,
error = viewState.error, error = viewState.error,
@ -137,16 +143,12 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
baseViewModel.setTitle(it) baseViewModel.setTitle(it)
baseViewModel.setError("") baseViewModel.setError("")
}, },
setColor = { baseViewModel.setColor(it) }, pickColor = { showThemePicker() },
clearColor = { onColorPicked(0) },
pickIcon = { showIconPicker() }, pickIcon = { showIconPicker() },
addShortcutToHome = { createShortcut(color) }, addShortcutToHome = { createShortcut(color) },
addWidgetToHome = { createWidget() }, addWidgetToHome = { createWidget() },
extensionContent = extensionContent, extensionContent = extensionContent,
purchase = {
startActivity(
Intent(this@BaseListSettingsActivity, PurchaseActivity::class.java)
)
},
) )
} }
} }
@ -199,7 +201,7 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
protected fun createWidget() { protected fun createWidget() {
val filter = filter ?: return val filter = filter ?: return
val appWidgetManager = getSystemService(AppWidgetManager::class.java) val appWidgetManager = getSystemService(AppWidgetManager::class.java)
if (appWidgetManager.isRequestPinAppWidgetSupported) { if (AndroidUtilities.atLeastOreo() && appWidgetManager.isRequestPinAppWidgetSupported) {
val provider = ComponentName(this, TasksWidget::class.java) val provider = ComponentName(this, TasksWidget::class.java)
val configIntent = Intent(this, RequestPinWidgetReceiver::class.java).apply { val configIntent = Intent(this, RequestPinWidgetReceiver::class.java).apply {
action = RequestPinWidgetReceiver.ACTION_CONFIGURE_WIDGET action = RequestPinWidgetReceiver.ACTION_CONFIGURE_WIDGET
@ -210,7 +212,7 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
this, this,
filter.hashCode(), filter.hashCode(),
configIntent, configIntent,
if (atLeastS()) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
) )
appWidgetManager.requestPinAppWidget(provider, null, successCallback) appWidgetManager.requestPinAppWidget(provider, null, successCallback)
firebase.logEvent(R.string.event_create_widget, R.string.param_type to "settings_activity") firebase.logEvent(R.string.event_create_widget, R.string.param_type to "settings_activity")
@ -218,6 +220,8 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
} }
companion object { companion object {
private const val FRAG_TAG_COLOR_PICKER = "frag_tag_color_picker"
fun createShortcutIcon(context: Context, backgroundColor: Color): IconCompat { fun createShortcutIcon(context: Context, backgroundColor: Color): IconCompat {
val size = context.resources.getDimensionPixelSize(android.R.dimen.app_icon_size) val size = context.resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)

@ -6,7 +6,7 @@ import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Help import androidx.compose.material.icons.outlined.Help
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -14,8 +14,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue 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.ui.Modifier
import androidx.compose.ui.draw.scale
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
@ -28,7 +26,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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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
@ -49,14 +47,13 @@ import org.tasks.filters.FilterCriteriaProvider
import org.tasks.filters.mapToSerializedString import org.tasks.filters.mapToSerializedString
import org.tasks.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
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 refreshBroadcaster: RefreshBroadcaster @Inject lateinit var localBroadcastManager: LocalBroadcastManager
private val viewModel: FilterSettingsViewModel by viewModels() private val viewModel: FilterSettingsViewModel by viewModels()
@ -128,7 +125,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
} else { } else {
filterDao.update(f) filterDao.update(f)
} }
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)
@ -173,18 +170,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
optionButton = { optionButton = {
if (isNew) { if (isNew) {
IconButton(onClick = { help() }) { IconButton(onClick = { help() }) {
// Cancel the mirroring of the help icon when the locale is Hebrew. Icon(imageVector = Icons.Outlined.Help, contentDescription = "")
val modifier =
if (Locale.getDefault().language == Locale.forLanguageTag("he").language) {
Modifier.scale(scaleX = -1f, scaleY = 1f)
} else {
Modifier
}
Icon(
imageVector = Icons.AutoMirrored.Outlined.Help,
contentDescription = "",
modifier = modifier,
)
} }
} else DeleteButton(filter?.title ?: ""){ delete() } } else DeleteButton(filter?.title ?: ""){ delete() }
}, },

@ -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.broadcast.RefreshBroadcaster 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.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 refreshBroadcaster: RefreshBroadcaster @Inject lateinit var localBroadcastManager: LocalBroadcastManager
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
) )
) )
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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) { if (LocalBroadcastManager.REFRESH == action || LocalBroadcastManager.REFRESH_LIST == 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.broadcast.RefreshBroadcaster 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.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 refreshBroadcaster: RefreshBroadcaster @Inject lateinit var localBroadcastManager: LocalBroadcastManager
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)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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))
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
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.broadcast.RefreshBroadcaster 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.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 refreshBroadcaster: RefreshBroadcaster @Inject lateinit var localBroadcastManager: LocalBroadcastManager
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 {
refreshBroadcaster.broadcastRefresh() localBroadcastManager.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)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefresh()
setResult( setResult(
Activity.RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)

@ -9,5 +9,4 @@ object Constants {
const val SYNC_TYPE_ETEBASE = "etebase" const val SYNC_TYPE_ETEBASE = "etebase"
const val SYNC_TYPE_DECSYNC = "decsync" const val SYNC_TYPE_DECSYNC = "decsync"
const val SYNC_TYPE_MICROSOFT = "microsoft" const val SYNC_TYPE_MICROSOFT = "microsoft"
const val SYNC_TYPE_LOCAL = "local"
} }

@ -0,0 +1,22 @@
package org.tasks.auth
import android.net.Uri
import androidx.core.net.toUri
data class IdentityProvider(
val name: String,
val discoveryEndpoint: Uri,
val clientId: String,
val redirectUri: Uri,
val scope: String
) {
companion object {
val MICROSOFT = IdentityProvider(
"Microsoft",
"https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration".toUri(),
"9d4babd5-e7ba-4286-ba4b-17274495a901",
"msauth://org.tasks/8wnYBRqh5nnQgFzbIXfxXSs41xE%3D".toUri(),
"user.read Tasks.ReadWrite openid offline_access email"
)
}
}

@ -39,10 +39,8 @@ import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT
import org.tasks.http.HttpClientFactory import org.tasks.http.HttpClientFactory
import org.tasks.jobs.WorkManager
import org.tasks.preferences.fragments.TasksAccountViewModel.Companion.getStringOrNull import org.tasks.preferences.fragments.TasksAccountViewModel.Companion.getStringOrNull
import org.tasks.security.KeyStoreEncryption import org.tasks.security.KeyStoreEncryption
import org.tasks.sync.SyncAdapters
import org.tasks.sync.microsoft.requestTokenExchange import org.tasks.sync.microsoft.requestTokenExchange
import javax.inject.Inject import javax.inject.Inject
@ -53,8 +51,6 @@ class MicrosoftAuthenticationActivity : ComponentActivity() {
@Inject lateinit var encryption: KeyStoreEncryption @Inject lateinit var encryption: KeyStoreEncryption
@Inject lateinit var httpClientFactory: HttpClientFactory @Inject lateinit var httpClientFactory: HttpClientFactory
@Inject lateinit var firebase: Firebase @Inject lateinit var firebase: Firebase
@Inject lateinit var syncAdapters: SyncAdapters
@Inject lateinit var workManager: WorkManager
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -97,8 +93,6 @@ class MicrosoftAuthenticationActivity : ComponentActivity() {
R.string.param_type to Constants.SYNC_TYPE_MICROSOFT R.string.param_type to Constants.SYNC_TYPE_MICROSOFT
) )
} }
syncAdapters.sync(true)
workManager.updateBackgroundSync()
finish() finish()
} else { } else {
error(ex?.message ?: "Token exchange failed") error(ex?.message ?: "Token exchange failed")
@ -155,3 +149,4 @@ class MicrosoftAuthenticationActivity : ComponentActivity() {
const val EXTRA_SERVICE_DISCOVERY = "extra_service_discovery" const val EXTRA_SERVICE_DISCOVERY = "extra_service_discovery"
} }
} }

@ -112,20 +112,6 @@ class TasksJsonExporter @Inject constructor(
} }
} }
suspend fun doSettingsExport(os: OutputStream?) = withContext(Dispatchers.IO) {
val writer = os!!.bufferedWriter()
with (JsonWriter(writer)) {
write("{")
write("version", BuildConfig.VERSION_CODE)
write("timestamp", currentTimeMillis())
write("\"data\":{")
writePreferences()
write("}")
write("}")
}
writer.flush()
}
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun doTasksExport(os: OutputStream?, taskIds: List<Long>) = withContext(Dispatchers.IO) { private suspend fun doTasksExport(os: OutputStream?, taskIds: List<Long>) = withContext(Dispatchers.IO) {
val writer = os!!.bufferedWriter() val writer = os!!.bufferedWriter()
@ -160,7 +146,11 @@ class TasksJsonExporter @Inject constructor(
write("caldavCalendars", caldavDao.getCalendars()) write("caldavCalendars", caldavDao.getCalendars())
write("taskListMetadata", taskListMetadataDao.getAll()) write("taskListMetadata", taskListMetadataDao.getAll())
write("taskAttachments", taskAttachmentDao.getAttachments()) write("taskAttachments", taskAttachmentDao.getAttachments())
writePreferences() write("intPrefs", preferences.getPrefs(Integer::class.java))
write("longPrefs", preferences.getPrefs(java.lang.Long::class.java))
write("stringPrefs", preferences.getPrefs(String::class.java))
write("boolPrefs", preferences.getPrefs(java.lang.Boolean::class.java))
write("setPrefs", preferences.getPrefs(Set::class.java) as Map<String, Set<String>>, lastItem = true)
write("}") write("}")
write("}") write("}")
} }
@ -169,14 +159,6 @@ class TasksJsonExporter @Inject constructor(
exportCount = taskIds.size exportCount = taskIds.size
} }
private fun JsonWriter.writePreferences() {
write("intPrefs", preferences.getPrefs(Integer::class.java))
write("longPrefs", preferences.getPrefs(java.lang.Long::class.java))
write("stringPrefs", preferences.getPrefs(String::class.java))
write("boolPrefs", preferences.getPrefs(java.lang.Boolean::class.java))
write("setPrefs", preferences.getPrefs(Set::class.java) as Map<String, Set<String>>, lastItem = true)
}
private fun onFinishExport(outputFile: String) = post { private fun onFinishExport(outputFile: String) = post {
context?.toast( context?.toast(
R.string.export_toast, R.string.export_toast,

@ -15,11 +15,9 @@ import com.todoroo.astrid.service.Upgrader.Companion.V12_4
import com.todoroo.astrid.service.Upgrader.Companion.V12_8 import com.todoroo.astrid.service.Upgrader.Companion.V12_8
import com.todoroo.astrid.service.Upgrader.Companion.V6_4 import com.todoroo.astrid.service.Upgrader.Companion.V6_4
import com.todoroo.astrid.service.Upgrader.Companion.getAndroidColor import com.todoroo.astrid.service.Upgrader.Companion.getAndroidColor
import kotlinx.coroutines.Dispatchers
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.broadcast.RefreshBroadcaster import org.tasks.LocalBroadcastManager
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 +63,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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
private val alarmDao: AlarmDao, private val alarmDao: AlarmDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val filterDao: FilterDao, private val filterDao: FilterDao,
@ -87,37 +85,9 @@ class TasksJsonImporter @Inject constructor(
handler.post { progressDialog.setMessage(message) } handler.post { progressDialog.setMessage(message) }
} }
suspend fun importTasks( suspend fun importTasks(context: Context, backupFile: Uri?, progressDialog: ProgressDialog?): ImportResult {
context: Context,
backupFile: Uri?,
progressDialog: ProgressDialog?
): ImportResult = withContext(Dispatchers.IO) {
Timber.d("Importing backup file $backupFile") Timber.d("Importing backup file $backupFile")
try { val handler = Handler(context.mainLooper)
val version = importMetadata(context, backupFile)
importTasks(context, backupFile, progressDialog, version)
if (version < Upgrader.V8_2) {
val themeIndex = preferences.getInt(R.string.p_theme_color, 7)
preferences.setInt(
R.string.p_theme_color,
getAndroidColor(context, themeIndex))
}
if (version < Upgrader.V9_6) {
taskMover.migrateLocalTasks()
}
Timber.d("Updating parents")
caldavDao.updateParents()
} catch (e: IOException) {
Timber.e(e)
}
refreshBroadcaster.broadcastRefresh()
result
}
private suspend fun importMetadata(
context: Context,
backupFile: Uri?,
): Int {
val `is`: InputStream? = try { val `is`: InputStream? = try {
context.contentResolver.openInputStream(backupFile!!) context.contentResolver.openInputStream(backupFile!!)
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {
@ -127,192 +97,167 @@ class TasksJsonImporter @Inject constructor(
val reader = JsonReader(bufferedReader) val reader = JsonReader(bufferedReader)
reader.isLenient = true reader.isLenient = true
val ignoreKeys = ignorePrefs.map { context.getString(it) } val ignoreKeys = ignorePrefs.map { context.getString(it) }
reader.beginObject() try {
var version = 0 reader.beginObject()
while (reader.hasNext()) { var version = 0
when (val name = reader.nextName()) { while (reader.hasNext()) {
"version" -> version = reader.nextInt().also { Timber.d("Backup version: $it") } when (val name = reader.nextName()) {
"timestamp" -> reader.nextLong().let { Timber.d("Backup timestamp: $it") } "version" -> version = reader.nextInt().also { Timber.d("Backup version: $it") }
"data" -> { "timestamp" -> reader.nextLong().let { Timber.d("Backup timestamp: $it") }
reader.beginObject() "data" -> {
while (reader.hasNext()) { reader.beginObject()
when (val element = reader.nextName()) { while (reader.hasNext()) {
"places" -> reader.forEach<Place> { place -> when (val element = reader.nextName()) {
if (locationDao.getByUid(place.uid!!) == null) { "tasks" -> {
locationDao.insert( reader.forEach<TaskBackup> { backup ->
place.copy(icon = place.icon.migrateLegacyIcon()) result.taskCount++
) setProgressMessage(
handler,
progressDialog,
context.getString(R.string.import_progress_read, result.taskCount))
importTask(backup, version)
}
} }
} "places" -> reader.forEach<Place> { place ->
"tags" -> reader.forEach<TagData> { tagData -> if (locationDao.getByUid(place.uid!!) == null) {
findTagData(tagData)?.let { locationDao.insert(
return@forEach place.copy(icon = place.icon.migrateLegacyIcon())
)
}
} }
tagDataDao.insert( "tags" -> reader.forEach<TagData> { tagData ->
tagData.copy( findTagData(tagData)?.let {
color = themeToColor(context, version, tagData.color ?: 0), return@forEach
icon = tagData.icon.migrateLegacyIcon(),
)
)
}
"filters" -> reader.forEach<Filter> {
it
.let {
if (version < Upgrade_13_2.VERSION)
filterCriteriaProvider.rebuildFilter(it)
else
it
} }
.let { filter -> tagDataDao.insert(
if (filterDao.getByName(filter.title!!) == null) { tagData.copy(
filterDao.insert( color = themeToColor(context, version, tagData.color ?: 0),
filter.copy( icon = tagData.icon.migrateLegacyIcon(),
color = themeToColor(context, version, filter.color ?: 0), )
icon = filter.icon.migrateLegacyIcon(), )
}
"filters" -> reader.forEach<Filter> {
it
.let {
if (version < Upgrade_13_2.VERSION)
filterCriteriaProvider.rebuildFilter(it)
else
it
}
.let { filter ->
if (filterDao.getByName(filter.title!!) == null) {
filterDao.insert(
filter.copy(
color = themeToColor(context, version, filter.color ?: 0),
icon = filter.icon.migrateLegacyIcon(),
)
) )
) }
} }
}
"caldavAccounts" -> reader.forEach<CaldavAccount> { account ->
if (caldavDao.getAccountByUuid(account.uuid!!) == null) {
caldavDao.insert(account)
} }
}
"caldavAccounts" -> reader.forEach<CaldavAccount> { account ->
if (caldavDao.getAccountByUuid(account.uuid!!) == null) {
caldavDao.insert(account)
} }
} "caldavCalendars" -> reader.forEach<CaldavCalendar> { calendar ->
"caldavCalendars" -> reader.forEach<CaldavCalendar> { calendar -> if (caldavDao.getCalendarByUuid(calendar.uuid!!) == null) {
if (caldavDao.getCalendarByUuid(calendar.uuid!!) == null) { caldavDao.insert(
caldavDao.insert( calendar.copy(
calendar.copy( color = themeToColor(context, version, calendar.color),
color = themeToColor(context, version, calendar.color), icon = calendar.icon.migrateLegacyIcon(),
icon = calendar.icon.migrateLegacyIcon(), )
) )
) }
} }
} "taskListMetadata" -> reader.forEach<TaskListMetadata> { tlm ->
"taskListMetadata" -> reader.forEach<TaskListMetadata> { tlm -> val id = tlm.filter.takeIf { it?.isNotBlank() == true } ?: tlm.tagUuid!!
val id = tlm.filter.takeIf { it?.isNotBlank() == true } ?: tlm.tagUuid!! if (taskListMetadataDao.fetchByTagOrFilter(id) == null) {
if (taskListMetadataDao.fetchByTagOrFilter(id) == null) { taskListMetadataDao.insert(tlm)
taskListMetadataDao.insert(tlm) }
} }
} "taskAttachments" -> reader.forEach<TaskAttachment> { attachment ->
"taskAttachments" -> reader.forEach<TaskAttachment> { attachment -> if (taskAttachmentDao.getAttachment(attachment.remoteId) == null) {
if (taskAttachmentDao.getAttachment(attachment.remoteId) == null) { taskAttachmentDao.insert(attachment)
taskAttachmentDao.insert(attachment) }
} }
} "intPrefs" ->
"intPrefs" -> Json.decodeFromString<Map<String, Integer>>(reader.jsonString())
Json.decodeFromString<Map<String, Integer>>(reader.jsonString()) .filterNot { (key, _) -> ignoreKeys.contains(key) }
.filterNot { (key, _) -> ignoreKeys.contains(key) } .forEach { (k, v) -> preferences.setInt(k, v as Int) }
.forEach { (k, v) -> preferences.setInt(k, v as Int) } "longPrefs" ->
"longPrefs" -> Json.decodeFromString<Map<String, java.lang.Long>>(reader.jsonString())
Json.decodeFromString<Map<String, java.lang.Long>>(reader.jsonString()) .filterNot { (key, _) -> ignoreKeys.contains(key) }
.filterNot { (key, _) -> ignoreKeys.contains(key) } .forEach { (k, v) -> preferences.setLong(k, v as Long)}
.forEach { (k, v) -> preferences.setLong(k, v as Long)} "stringPrefs" ->
"stringPrefs" -> Json.decodeFromString<Map<String, String>>(reader.jsonString())
Json.decodeFromString<Map<String, String>>(reader.jsonString()) .filterNot { (k, _) -> ignoreKeys.contains(k) }
.filterNot { (k, _) -> ignoreKeys.contains(k) } .forEach { (k, v) -> preferences.setString(k, v)}
.forEach { (k, v) -> preferences.setString(k, v)} "boolPrefs" ->
"boolPrefs" -> Json.decodeFromString<Map<String, java.lang.Boolean>>(reader.jsonString())
Json.decodeFromString<Map<String, java.lang.Boolean>>(reader.jsonString()) .filterNot { (k, _) -> ignoreKeys.contains(k) }
.filterNot { (k, _) -> ignoreKeys.contains(k) } .forEach { (k, v) -> preferences.setBoolean(k, v as Boolean) }
.forEach { (k, v) -> preferences.setBoolean(k, v as Boolean) } "setPrefs" ->
"setPrefs" -> Json.decodeFromString<Map<String, Set<String>>>(reader.jsonString())
Json.decodeFromString<Map<String, Set<String>>>(reader.jsonString()) .filterNot { (k, _) -> ignoreKeys.contains(k) }
.filterNot { (k, _) -> ignoreKeys.contains(k) } .forEach { (k, v) -> preferences.setStringSet(k, v as HashSet<String>)}
.forEach { (k, v) -> preferences.setStringSet(k, v as HashSet<String>)} "googleTaskAccounts" -> reader.forEach<GoogleTaskAccount> { googleTaskAccount ->
"googleTaskAccounts" -> reader.forEach<GoogleTaskAccount> { googleTaskAccount -> if (caldavDao.getAccount(TYPE_GOOGLE_TASKS, googleTaskAccount.account!!) == null) {
if (caldavDao.getAccount(TYPE_GOOGLE_TASKS, googleTaskAccount.account!!) == null) { caldavDao.insert(
caldavDao.insert( CaldavAccount(
CaldavAccount( accountType = TYPE_GOOGLE_TASKS,
accountType = TYPE_GOOGLE_TASKS, uuid = googleTaskAccount.account,
uuid = googleTaskAccount.account, name = googleTaskAccount.account,
name = googleTaskAccount.account, username = googleTaskAccount.account,
username = googleTaskAccount.account, )
) )
) }
} }
} "googleTaskLists" -> reader.forEach<GoogleTaskList> { googleTaskList ->
"googleTaskLists" -> reader.forEach<GoogleTaskList> { googleTaskList -> if (caldavDao.getCalendar(googleTaskList.remoteId!!) == null) {
if (caldavDao.getCalendar(googleTaskList.remoteId!!) == null) { caldavDao.insert(
caldavDao.insert( CaldavCalendar(
CaldavCalendar( account = googleTaskList.account,
account = googleTaskList.account, uuid = googleTaskList.remoteId,
uuid = googleTaskList.remoteId, color = themeToColor(context, version, googleTaskList.color ?: 0),
color = themeToColor(context, version, googleTaskList.color ?: 0), icon = googleTaskList.icon?.toString().migrateLegacyIcon(),
icon = googleTaskList.icon?.toString().migrateLegacyIcon(), )
) )
) }
} }
} else -> {
else -> { Timber.w("Skipping $element")
Timber.w("Skipping $element") reader.skipValue()
reader.skipValue()
}
}
}
reader.endObject()
}
else -> {
Timber.w("Skipping $name")
reader.skipValue()
}
}
}
reader.close()
bufferedReader.close()
`is`.close()
return version
}
private suspend fun importTasks(
context: Context,
backupFile: Uri?,
progressDialog: ProgressDialog?,
version: Int,
) {
val handler = Handler(context.mainLooper)
val `is`: InputStream? = try {
context.contentResolver.openInputStream(backupFile!!)
} catch (e: FileNotFoundException) {
throw IllegalStateException(e)
}
val bufferedReader = `is`!!.bufferedReader()
val reader = JsonReader(bufferedReader)
reader.isLenient = true
reader.beginObject()
while (reader.hasNext()) {
when (val name = reader.nextName()) {
"data" -> {
reader.beginObject()
while (reader.hasNext()) {
when (val element = reader.nextName()) {
"tasks" -> {
reader.forEach<TaskBackup> { backup ->
result.taskCount++
setProgressMessage(
handler,
progressDialog,
context.getString(R.string.import_progress_read, result.taskCount))
importTask(backup, version)
} }
} }
else -> {
Timber.w("Skipping $element")
reader.skipValue()
}
} }
reader.endObject()
}
else -> {
Timber.w("Skipping $name")
reader.skipValue()
} }
reader.endObject()
}
else -> {
Timber.w("Skipping $name")
reader.skipValue()
} }
} }
if (version < Upgrader.V8_2) {
val themeIndex = preferences.getInt(R.string.p_theme_color, 7)
preferences.setInt(
R.string.p_theme_color,
getAndroidColor(context, themeIndex))
}
if (version < Upgrader.V9_6) {
taskMover.migrateLocalTasks()
}
Timber.d("Updating parents")
caldavDao.updateParents()
reader.close()
bufferedReader.close()
`is`!!.close()
} catch (e: IOException) {
Timber.e(e)
} }
reader.close() localBroadcastManager.broadcastRefresh()
bufferedReader.close() return result
`is`.close()
} }
private suspend fun importTask(backup: TaskBackup, version: Int) { private suspend fun importTask(backup: TaskBackup, version: Int) {

@ -8,16 +8,10 @@ import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -42,7 +36,6 @@ import org.tasks.compose.ServerSelector
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.CaldavAccount.Companion.SERVER_UNKNOWN import org.tasks.data.entity.CaldavAccount.Companion.SERVER_UNKNOWN
import org.tasks.data.entity.CaldavAccount.Companion.TYPE_LOCAL
import org.tasks.data.entity.Task import org.tasks.data.entity.Task
import org.tasks.databinding.ActivityCaldavAccountSettingsBinding import org.tasks.databinding.ActivityCaldavAccountSettingsBinding
import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.DialogBuilder
@ -76,18 +69,8 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityCaldavAccountSettingsBinding.inflate(layoutInflater) binding = ActivityCaldavAccountSettingsBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
binding.toolbar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemBars.top
}
binding.rootLayout.updatePadding(bottom = systemBars.bottom)
insets
}
caldavAccount = if (savedInstanceState == null) intent.getParcelableExtra(EXTRA_CALDAV_DATA) else savedInstanceState.getParcelable(EXTRA_CALDAV_DATA) caldavAccount = if (savedInstanceState == null) intent.getParcelableExtra(EXTRA_CALDAV_DATA) else savedInstanceState.getParcelable(EXTRA_CALDAV_DATA)
serverType = mutableStateOf( serverType = mutableStateOf(
savedInstanceState?.getInt(EXTRA_SERVER_TYPE, SERVER_UNKNOWN) savedInstanceState?.getInt(EXTRA_SERVER_TYPE, SERVER_UNKNOWN)
@ -133,7 +116,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(binding.name, InputMethodManager.SHOW_IMPLICIT) imm.showSoftInput(binding.name, InputMethodManager.SHOW_IMPLICIT)
} }
if (!inventory.hasPro && caldavAccount?.accountType != TYPE_LOCAL) { if (!inventory.hasPro) {
newSnackbar(getString(R.string.this_feature_requires_a_subscription)) newSnackbar(getString(R.string.this_feature_requires_a_subscription))
.setDuration(BaseTransientBottomBar.LENGTH_INDEFINITE) .setDuration(BaseTransientBottomBar.LENGTH_INDEFINITE)
.setAction(R.string.button_subscribe) { .setAction(R.string.button_subscribe) {
@ -325,8 +308,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
private fun newSnackbar(message: String?): Snackbar { private fun newSnackbar(message: String?): Snackbar {
val snackbar = Snackbar.make(binding.rootLayout, message!!, 8000) val snackbar = Snackbar.make(binding.rootLayout, message!!, 8000)
.setBackgroundTint(getColor(R.color.dialog_background)) .setTextColor(getColor(R.color.snackbar_text_color))
.setTextColor(getColor(R.color.text_primary))
.setActionTextColor(getColor(R.color.snackbar_action_color)) .setActionTextColor(getColor(R.color.snackbar_action_color))
snackbar snackbar
.view .view
@ -359,7 +341,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
} }
} }
protected open suspend fun removeAccountPrompt() { private fun removeAccountPrompt() {
if (requestInProgress()) { if (requestInProgress()) {
return return
} }
@ -396,9 +378,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.menu_help -> openUri(helpUrl) R.id.menu_help -> openUri(helpUrl)
R.id.remove -> lifecycleScope.launch { R.id.remove -> removeAccountPrompt()
removeAccountPrompt()
}
} }
return onOptionsItemSelected(item) return onOptionsItemSelected(item)
} }

@ -78,10 +78,11 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
showProgressIndicator() showProgressIndicator()
createCalendar(caldavAccount, name, baseViewModel.color) createCalendar(caldavAccount, name, baseViewModel.color)
} }
nameChanged() || colorChanged() || iconChanged() -> { nameChanged() || colorChanged() -> {
showProgressIndicator() showProgressIndicator()
updateNameAndColor(caldavAccount, caldavCalendar!!, name, baseViewModel.color) updateNameAndColor(caldavAccount, caldavCalendar!!, name, baseViewModel.color)
} }
iconChanged() -> updateCalendar()
else -> finish() else -> finish()
} }
} }
@ -149,7 +150,7 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
) )
caldavDao.update(result) caldavDao.update(result)
setResult( setResult(
RESULT_OK, Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD) Intent(TaskListFragment.ACTION_RELOAD)
.putExtra( .putExtra(
MainActivity.OPEN_FILTER, MainActivity.OPEN_FILTER,

@ -15,6 +15,7 @@ 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
@ -34,6 +35,7 @@ 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
@ -70,7 +72,7 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
val openDialog = rememberSaveable { mutableStateOf(false) } val openDialog = rememberSaveable { mutableStateOf(false) }
ShareInviteDialog( ShareInviteDialog(
openDialog, openDialog,
email = caldavAccount.serverType !in listOf(SERVER_OWNCLOUD, SERVER_NEXTCLOUD), email = caldavAccount.serverType != SERVER_OWNCLOUD
) { input -> ) { input ->
lifecycleScope.launch { lifecycleScope.launch {
share(input) share(input)

@ -12,8 +12,6 @@ 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
@ -39,7 +37,7 @@ class CaldavCalendarViewModel @Inject constructor(
): CaldavCalendar? = ): CaldavCalendar? =
doRequest { doRequest {
val url = withContext(Dispatchers.IO) { val url = withContext(Dispatchers.IO) {
provider.forAccount(caldavAccount).makeCollection(name, color, icon) provider.forAccount(caldavAccount).makeCollection(name, color)
} }
val calendar = CaldavCalendar( val calendar = CaldavCalendar(
uuid = UUIDHelper.newUUID(), uuid = UUIDHelper.newUUID(),
@ -69,7 +67,7 @@ class CaldavCalendarViewModel @Inject constructor(
) = ) =
doRequest { doRequest {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
provider.forAccount(account, calendar.url!!).updateCollection(name, color, icon) provider.forAccount(account, calendar.url!!).updateCollection(name, color)
} }
val result = calendar.copy( val result = calendar.copy(
name = name, name = name,
@ -98,10 +96,10 @@ class CaldavCalendarViewModel @Inject constructor(
list: CaldavCalendar, list: CaldavCalendar,
input: String input: String
) = doRequest { ) = doRequest {
val href = when (account.serverType) { val href = if (account.serverType == CaldavAccount.SERVER_OWNCLOUD)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> "principal:principals/users/$input" "principal:principals/users/$input"
else -> "mailto:$input" else
} "mailto:$input"
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
provider.forAccount(account, list.url!!).share(account, href) provider.forAccount(account, list.url!!).share(account, href)
} }

@ -11,32 +11,24 @@ import at.bitfire.dav4jvm.XmlUtils.NS_CALDAV
import at.bitfire.dav4jvm.XmlUtils.NS_WEBDAV import at.bitfire.dav4jvm.XmlUtils.NS_WEBDAV
import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CalendarColor import at.bitfire.dav4jvm.property.*
import at.bitfire.dav4jvm.property.CalendarHomeSet
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.dav4jvm.property.ResourceType.Companion.CALENDAR import at.bitfire.dav4jvm.property.ResourceType.Companion.CALENDAR
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet import org.tasks.data.UUIDHelper
import at.bitfire.dav4jvm.property.SyncToken
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.caldav.property.CalendarIcon
import org.tasks.caldav.property.Invite import org.tasks.caldav.property.Invite
import org.tasks.caldav.property.OCInvite import org.tasks.caldav.property.OCInvite
import org.tasks.caldav.property.OCOwnerPrincipal import org.tasks.caldav.property.OCOwnerPrincipal
import org.tasks.caldav.property.PropertyUtils.NS_OWNCLOUD import org.tasks.caldav.property.PropertyUtils.NS_OWNCLOUD
import org.tasks.caldav.property.ShareAccess import org.tasks.caldav.property.ShareAccess
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_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
@ -109,12 +101,9 @@ open class CaldavClient(
.findHomeset() .findHomeset()
} }
suspend fun calendars(interceptor: (okhttp3.Response) -> okhttp3.Response = { it }): List<Response> = suspend fun calendars(interceptor: (Interceptor.Chain) -> okhttp3.Response): List<Response> =
DavResource( DavResource(
httpClient httpClient.newBuilder().addNetworkInterceptor(interceptor).build(),
.newBuilder()
.addNetworkInterceptor { interceptor(it.proceed(it.request())) }
.build(),
httpUrl!! httpUrl!!
) )
.propfind(1, *calendarProperties) .propfind(1, *calendarProperties)
@ -131,44 +120,33 @@ open class CaldavClient(
} }
@Throws(IOException::class, XmlPullParserException::class, HttpException::class) @Throws(IOException::class, XmlPullParserException::class, HttpException::class)
suspend fun makeCollection(displayName: String, color: Int, icon: String?): String = withContext(Dispatchers.IO) { suspend fun makeCollection(displayName: String, color: Int): String = withContext(Dispatchers.IO) {
val davResource = DavResource(httpClient, httpUrl!!.resolve(UUIDHelper.newUUID() + "/")!!) val davResource = DavResource(httpClient, httpUrl!!.resolve(UUIDHelper.newUUID() + "/")!!)
val mkcolString = getMkcolString(displayName, color) val mkcolString = getMkcolString(displayName, color)
davResource.mkCol(mkcolString) {} davResource.mkCol(mkcolString) {}
if (icon?.isNotBlank() == true) {
davResource.proppatch(CalendarIcon.NAME, icon)
}
davResource.location.toString() davResource.location.toString()
} }
@Throws(IOException::class, XmlPullParserException::class, HttpException::class) @Throws(IOException::class, XmlPullParserException::class, HttpException::class)
suspend fun updateCollection(displayName: String, color: Int, icon: String?): String = suspend fun updateCollection(displayName: String, color: Int): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
with(DavResource(httpClient, httpUrl!!)) { with(DavResource(httpClient, httpUrl!!)) {
proppatch(DisplayName.NAME, displayName) proppatch(
if (color != 0) { setProperties = mutableMapOf(DisplayName.NAME to displayName).apply {
proppatch( if (color != 0) {
CalendarColor.NAME, put(
String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24) CalendarColor.NAME,
) String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24)
} )
if (icon?.isNotBlank() == true) { }
proppatch(CalendarIcon.NAME, icon) },
} removeProperties = if (color == 0) listOf(CalendarColor.NAME) else emptyList(),
callback = { _, _ -> },
)
location.toString() location.toString()
} }
} }
@Throws(IOException::class, XmlPullParserException::class, HttpException::class)
suspend fun updateIcon(url: HttpUrl, icon: String?, onFailure: () -> Unit) =
withContext(Dispatchers.IO) {
with(DavResource(httpClient, url)) {
if (icon?.isNotBlank() == true) {
proppatch(CalendarIcon.NAME, icon, onFailure)
}
}
}
@Throws(IOException::class, XmlPullParserException::class) @Throws(IOException::class, XmlPullParserException::class)
private fun getMkcolString(displayName: String, color: Int): String { private fun getMkcolString(displayName: String, color: Int): String {
val xmlPullParserFactory = XmlPullParserFactory.newInstance() val xmlPullParserFactory = XmlPullParserFactory.newInstance()
@ -225,8 +203,8 @@ open class CaldavClient(
href: String, href: String,
) { ) {
when (account.serverType) { when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV -> shareSabredav(href) SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> shareSabredav(href)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> shareOwncloud(href) SERVER_OWNCLOUD -> shareOwncloud(href)
else -> throw IllegalArgumentException() else -> throw IllegalArgumentException()
} }
} }
@ -265,8 +243,8 @@ open class CaldavClient(
href: String, href: String,
) { ) {
when (account.serverType) { when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV -> removeSabrePrincipal(calendar, href) SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> removeSabrePrincipal(calendar, href)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> removeOwncloudPrincipal(calendar, href) SERVER_OWNCLOUD -> removeOwncloudPrincipal(calendar, href)
else -> throw IllegalArgumentException() else -> throw IllegalArgumentException()
} }
} }
@ -306,19 +284,18 @@ open class CaldavClient(
private val MEDIATYPE_SHARING = "application/davsharing+xml".toMediaType() private val MEDIATYPE_SHARING = "application/davsharing+xml".toMediaType()
private val calendarProperties = arrayOf( private val calendarProperties = arrayOf(
ResourceType.NAME, ResourceType.NAME,
DisplayName.NAME, DisplayName.NAME,
SupportedCalendarComponentSet.NAME, SupportedCalendarComponentSet.NAME,
GetCTag.NAME, GetCTag.NAME,
CalendarColor.NAME, CalendarColor.NAME,
SyncToken.NAME, SyncToken.NAME,
ShareAccess.NAME, ShareAccess.NAME,
Invite.NAME, Invite.NAME,
OCOwnerPrincipal.NAME, OCOwnerPrincipal.NAME,
OCInvite.NAME, OCInvite.NAME,
CurrentUserPrivilegeSet.NAME, CurrentUserPrivilegeSet.NAME,
CurrentUserPrincipal.NAME, CurrentUserPrincipal.NAME,
CalendarIcon.NAME,
) )
private suspend fun DavResource.propfind( private suspend fun DavResource.propfind(
@ -334,22 +311,5 @@ open class CaldavClient(
cont.resumeWith(Result.success(responses)) cont.resumeWith(Result.success(responses))
} }
} }
fun DavResource.proppatch(
property: Property.Name,
value: String,
onFailure: () -> Unit = {},
) {
proppatch(
setProperties = mapOf(property to value),
removeProperties = emptyList(),
callback = { response, _ ->
if (!response.isSuccess()) {
Timber.e("${response.status} when updating $property: ${response.error}")
onFailure()
}
},
)
}
} }
} }

@ -32,18 +32,18 @@ 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.Invite import org.tasks.caldav.property.Invite
import org.tasks.caldav.property.OCAccess import org.tasks.caldav.property.OCAccess
import org.tasks.caldav.property.OCInvite import org.tasks.caldav.property.OCInvite
import org.tasks.caldav.property.OCOwnerPrincipal import org.tasks.caldav.property.OCOwnerPrincipal
import org.tasks.caldav.property.OCUser import org.tasks.caldav.property.OCUser
import org.tasks.caldav.property.PropertyUtils.register
import org.tasks.caldav.property.ShareAccess import org.tasks.caldav.property.ShareAccess
import org.tasks.caldav.property.ShareAccess.Companion.NOT_SHARED import org.tasks.caldav.property.ShareAccess.Companion.NOT_SHARED
import org.tasks.caldav.property.ShareAccess.Companion.NO_ACCESS import org.tasks.caldav.property.ShareAccess.Companion.NO_ACCESS
@ -56,7 +56,6 @@ 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
@ -85,7 +84,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 refreshBroadcaster: RefreshBroadcaster, private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter, private val taskDeleter: TaskDeleter,
private val inventory: Inventory, private val inventory: Inventory,
private val firebase: Firebase, private val firebase: Firebase,
@ -137,7 +136,8 @@ class CaldavSynchronizer @Inject constructor(
private suspend fun synchronize(account: CaldavAccount) { private suspend fun synchronize(account: CaldavAccount) {
val caldavClient = provider.forAccount(account) val caldavClient = provider.forAccount(account)
var serverType = account.serverType var serverType = account.serverType
val resources = caldavClient.calendars { response -> val resources = caldavClient.calendars { chain ->
val response = chain.proceed(chain.request())
if (serverType == SERVER_UNKNOWN) { if (serverType == SERVER_UNKNOWN) {
serverType = getServerType(account, response.headers) serverType = getServerType(account, response.headers)
} }
@ -155,10 +155,8 @@ class CaldavSynchronizer @Inject constructor(
val url = resource.href.toString() val url = resource.href.toString()
var calendar = caldavDao.getCalendarByUrl(account.uuid!!, url) var calendar = caldavDao.getCalendarByUrl(account.uuid!!, url)
val remoteName = resource[DisplayName::class.java]!!.displayName val remoteName = resource[DisplayName::class.java]!!.displayName
val color = resource[CalendarColor::class.java]?.color ?: 0 val calendarColor = resource[CalendarColor::class.java]
val access = resource.accessLevel val access = resource.accessLevel
val icon = resource[CalendarIcon::class.java]?.icon?.takeIf { it.isNotBlank() }
if (access == ACCESS_UNKNOWN) { if (access == ACCESS_UNKNOWN) {
firebase.logEvent( firebase.logEvent(
R.string.event_sync_unknown_access, R.string.event_sync_unknown_access,
@ -166,6 +164,7 @@ class CaldavSynchronizer @Inject constructor(
(resource[ShareAccess::class.java]?.access?.toString() ?: "???") (resource[ShareAccess::class.java]?.access?.toString() ?: "???")
) )
} }
val color = calendarColor?.color ?: 0
if (calendar == null) { if (calendar == null) {
calendar = CaldavCalendar( calendar = CaldavCalendar(
name = remoteName, name = remoteName,
@ -174,22 +173,17 @@ class CaldavSynchronizer @Inject constructor(
uuid = UUIDHelper.newUUID(), uuid = UUIDHelper.newUUID(),
color = color, color = color,
access = access, access = access,
icon = icon,
) )
caldavDao.insert(calendar) caldavDao.insert(calendar)
} else if (calendar.name != remoteName } else if (calendar.name != remoteName
|| calendar.color != color || calendar.color != color
|| calendar.access != access || calendar.access != access
|| (icon != null && calendar.icon != icon)
) { ) {
calendar = calendar.copy( calendar.color = color
color = color, calendar.name = remoteName
name = remoteName, calendar.access = access
access = access,
icon = icon ?: calendar.icon,
)
caldavDao.update(calendar) caldavDao.update(calendar)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
} }
resource resource
.principals(account, calendar) .principals(account, calendar)
@ -204,11 +198,7 @@ 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 -> headers["DAV"]?.contains("oc-resource-sharing") == true -> SERVER_OWNCLOUD
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
@ -225,7 +215,7 @@ class CaldavSynchronizer @Inject constructor(
} }
account.error = message account.error = message
caldavDao.update(account) caldavDao.update(account)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefreshList()
if (!isNullOrEmpty(message)) { if (!isNullOrEmpty(message)) {
Timber.e(message) Timber.e(message)
} }
@ -302,7 +292,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!!)
refreshBroadcaster.broadcastRefresh() localBroadcastManager.broadcastRefresh()
} }
private suspend fun pushLocalChanges( private suspend fun pushLocalChanges(
@ -330,17 +320,9 @@ class CaldavSynchronizer @Inject constructor(
caldavTask: CaldavTask caldavTask: CaldavTask
): Boolean { ): Boolean {
try { try {
val objectId = caldavTask.obj if (!isNullOrEmpty(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 = httpClient, httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build())
location = httpUrl.newBuilder().addPathSegment(objectId).build(),
)
remote.delete(null) {} remote.delete(null) {}
} }
} catch (e: HttpException) { } catch (e: HttpException) {
@ -364,8 +346,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)
@ -374,19 +356,9 @@ 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 = httpClient, httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build())
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 ->
@ -464,13 +436,10 @@ class CaldavSynchronizer @Inject constructor(
fun registerFactories() { fun registerFactories() {
PropertyRegistry.register( PropertyRegistry.register(
listOf( ShareAccess.Factory(),
ShareAccess.Factory(), Invite.Factory(),
Invite.Factory(), OCOwnerPrincipal.Factory(),
OCOwnerPrincipal.Factory(), OCInvite.Factory(),
OCInvite.Factory(),
CalendarIcon.Factory,
)
) )
} }
@ -519,4 +488,4 @@ class CaldavSynchronizer @Inject constructor(
else -> INVITE_UNKNOWN else -> INVITE_UNKNOWN
} }
} }
} }

@ -1,13 +1,16 @@
package org.tasks.caldav package org.tasks.caldav
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import javax.inject.Inject
class FileStorage( class FileStorage @Inject constructor(
rootPath: String @ApplicationContext context: Context
) { ) {
val root = File(rootPath, "vtodo") val root = File(context.filesDir, "vtodo")
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
fun getFile(vararg segments: String?): File? = fun getFile(vararg segments: String?): File? =

@ -1,90 +0,0 @@
package org.tasks.caldav
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.analytics.Constants
import org.tasks.data.UUIDHelper
import org.tasks.data.entity.CaldavAccount
@AndroidEntryPoint
class LocalAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolbar.OnMenuItemClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.userLayout.visibility = View.GONE
binding.passwordLayout.visibility = View.GONE
binding.urlLayout.visibility = View.GONE
binding.serverSelector.visibility = View.GONE
}
override fun hasChanges() = newName != caldavAccount!!.name
override fun save() = lifecycleScope.launch {
if (newName.isBlank()) {
binding.nameLayout.error = getString(R.string.name_cannot_be_empty)
return@launch
}
updateAccount()
}
private suspend fun addAccount() {
caldavDao.insert(
CaldavAccount(
name = newName,
uuid = UUIDHelper.newUUID(),
)
)
firebase.logEvent(
R.string.event_sync_add_account,
R.string.param_type to Constants.SYNC_TYPE_LOCAL
)
setResult(Activity.RESULT_OK)
finish()
}
override suspend fun updateAccount() {
caldavAccount!!.name = newName
caldavDao.update(caldavAccount!!)
setResult(Activity.RESULT_OK)
finish()
}
override suspend fun addAccount(url: String, username: String, password: String) {
addAccount()
}
override suspend fun updateAccount(url: String, username: String, password: String) {
updateAccount()
}
override suspend fun removeAccountPrompt() {
val countTasks = caldavAccount?.uuid?.let { caldavDao.countTasks(it) } ?: 0
val countString = resources.getQuantityString(R.plurals.task_count, countTasks, countTasks)
dialogBuilder
.newDialog()
.setTitle(
R.string.delete_tag_confirmation,
caldavAccount?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.local_lists)
)
.apply {
if (countTasks > 0) {
setMessage(R.string.delete_tasks_warning, countString)
} else {
setMessage(R.string.logout_warning)
}
}
.setPositiveButton(R.string.delete) { _, _ -> lifecycleScope.launch { removeAccount() } }
.setNegativeButton(R.string.cancel, null)
.show()
}
override val newPassword: String? = null
override val helpUrl = R.string.url_caldav
}

@ -3,20 +3,26 @@ package org.tasks.caldav
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import org.tasks.compose.DeleteButton import org.tasks.compose.DeleteButton
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
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
@AndroidEntryPoint @AndroidEntryPoint
class LocalListSettingsActivity : BaseCaldavCalendarSettingsActivity() { class LocalListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val canDelete = runBlocking { caldavDao.getCalendarsByAccount(CaldavDao.LOCAL).size > 1 }
setContent { setContent {
TasksTheme { TasksTheme {
BaseCaldavSettingsContent ( BaseCaldavSettingsContent (
optionButton = { if (!isNew) DeleteButton(caldavCalendar?.name ?: "") { delete() } } optionButton = { if (!isNew && canDelete) DeleteButton(caldavCalendar?.name ?: "") { delete() } }
) )
} }
} }
@ -29,7 +35,6 @@ class LocalListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) = account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) =
updateCalendar() updateCalendar()
// TODO: prevent deleting the last list
override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) = override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) =
onDeleted(true) onDeleted(true)
} }

@ -1,15 +1,18 @@
package org.tasks.caldav package org.tasks.caldav
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
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
import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.CaldavTask
import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
class VtodoCache( @Singleton
class VtodoCache @Inject constructor(
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val fileStorage: FileStorage, private val fileStorage: FileStorage,
) { ) {
@ -27,7 +30,7 @@ class VtodoCache(
?: return@withContext ?: return@withContext
source.copyTo(target, overwrite = true) source.copyTo(target, overwrite = true)
val deleted = source.delete() val deleted = source.delete()
Logger.d("VtodoCache") { "Moved $source to $target [success=${deleted}]" } Timber.d("Moved $source to $target [success=${deleted}]")
} }
suspend fun getVtodo(caldavTask: CaldavTask?): String? { suspend fun getVtodo(caldavTask: CaldavTask?): String? {
@ -66,28 +69,28 @@ class VtodoCache(
suspend fun delete(calendar: CaldavCalendar, caldavTask: CaldavTask) = withContext(Dispatchers.IO) { suspend fun delete(calendar: CaldavCalendar, caldavTask: CaldavTask) = withContext(Dispatchers.IO) {
fileStorage.getFile(calendar.account, caldavTask.calendar, caldavTask.obj)?.let { fileStorage.getFile(calendar.account, caldavTask.calendar, caldavTask.obj)?.let {
val deleted = it.delete() val deleted = it.delete()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" } Timber.d("Deleting $it [success=$deleted]")
} }
} }
suspend fun delete(calendar: CaldavCalendar) = withContext(Dispatchers.IO) { suspend fun delete(calendar: CaldavCalendar) = withContext(Dispatchers.IO) {
fileStorage.getFile(calendar.account, calendar.uuid)?.let { fileStorage.getFile(calendar.account, calendar.uuid)?.let {
val deleted = it.deleteRecursively() val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" } Timber.d("Deleting $it [success=$deleted]")
} }
} }
suspend fun delete(account: CaldavAccount) = withContext(Dispatchers.IO) { suspend fun delete(account: CaldavAccount) = withContext(Dispatchers.IO) {
fileStorage.getFile(account.uuid)?.let { fileStorage.getFile(account.uuid)?.let {
val deleted = it.deleteRecursively() val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" } Timber.d("Deleting $it [success=$deleted]")
} }
} }
suspend fun clear() = withContext(Dispatchers.IO) { suspend fun clear() = withContext(Dispatchers.IO) {
fileStorage.getFile()?.let { fileStorage.getFile()?.let {
val deleted = it.deleteRecursively() val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" } Timber.d("Deleting $it [success=$deleted]")
} }
} }
} }

@ -41,6 +41,7 @@ import org.tasks.data.entity.Alarm.Companion.TYPE_RANDOM
import org.tasks.data.entity.Alarm.Companion.TYPE_SNOOZE import org.tasks.data.entity.Alarm.Companion.TYPE_SNOOZE
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_ONLY
import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.CaldavTask
import org.tasks.data.entity.Place import org.tasks.data.entity.Place
import org.tasks.data.entity.TagData import org.tasks.data.entity.TagData
@ -207,7 +208,7 @@ class iCalendar @Inject constructor(
val task = existing?.task val task = existing?.task
?.let { taskDao.fetch(it) } ?.let { taskDao.fetch(it) }
?: taskCreator.createWithValues("").apply { ?: taskCreator.createWithValues("").apply {
readOnly = calendar.readOnly() readOnly = calendar.access == ACCESS_READ_ONLY
taskDao.createNew(this) taskDao.createNew(this)
} }
val caldavTask = val caldavTask =

@ -1,32 +0,0 @@
package org.tasks.caldav.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
data class CalendarIcon(
val icon: String,
): Property {
companion object Companion {
@JvmField
val NAME = Property.Name(PropertyUtils.NS_TASKS, "x-calendar-icon")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CalendarIcon? {
XmlUtils.readText(parser)?.takeIf { it.isNotBlank() }?.let {
try {
return CalendarIcon(it)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Couldn't parse icon: $it")
}
}
return null
}
}
}

@ -1,6 +1,10 @@
package org.tasks.caldav.property package org.tasks.caldav.property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.PropertyRegistry
object PropertyUtils { object PropertyUtils {
const val NS_TASKS = "http://org.tasks/ns/"
const val NS_OWNCLOUD = "http://owncloud.org/ns" const val NS_OWNCLOUD = "http://owncloud.org/ns"
}
fun PropertyRegistry.register(vararg factories: PropertyFactory) = register(factories.toList())
}

@ -16,6 +16,7 @@ import org.tasks.themes.TasksTheme
fun AddAccountDialog( fun AddAccountDialog(
hasTasksAccount: Boolean, hasTasksAccount: Boolean,
hasPro: Boolean, hasPro: Boolean,
enableMicrosoftSync: Boolean = true,
selected: (Platform) -> Unit, selected: (Platform) -> Unit,
) { ) {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@ -35,13 +36,15 @@ fun AddAccountDialog(
icon = R.drawable.ic_google, icon = R.drawable.ic_google,
onClick = { selected(Platform.GOOGLE_TASKS) } onClick = { selected(Platform.GOOGLE_TASKS) }
) )
SyncAccount( if (enableMicrosoftSync) {
title = R.string.microsoft, SyncAccount(
cost = if (hasPro) null else R.string.cost_free, title = R.string.microsoft,
description = R.string.microsoft_selection_description, cost = if (hasPro) null else R.string.cost_free,
icon = R.drawable.ic_microsoft_tasks, description = R.string.microsoft_selection_description,
onClick = { selected(Platform.MICROSOFT) } icon = R.drawable.ic_microsoft_tasks,
) onClick = { selected(Platform.MICROSOFT) }
)
}
SyncAccount( SyncAccount(
title = R.string.davx5, title = R.string.davx5,
cost = if (hasPro) null else R.string.cost_money, cost = if (hasPro) null else R.string.cost_money,

@ -25,11 +25,10 @@ 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.getValue import androidx.compose.runtime.MutableState
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
@ -63,99 +62,141 @@ 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(
alarm: Alarm?, viewState: ViewState,
updateAlarm: (Alarm) -> Unit, addAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
// Create working copy from alarm or use defaults val time = rememberSaveable { mutableStateOf(15) }
var workingCopy by rememberSaveable { val units = rememberSaveable { mutableStateOf(0) }
mutableStateOf(alarm ?: Alarm(time = 15 * TimeUnit.MINUTES.toMillis(1), type = TYPE_RANDOM)) if (viewState.showRandomDialog) {
AlertDialog(
onDismissRequest = closeDialog,
text = { AddRandomReminder(time, units) },
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it > 0 }?.let { i ->
addAlarm(Alarm(time = i * units.millis, type = TYPE_RANDOM))
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
)
} else {
time.value = 15
units.value = 0
} }
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddRandomReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (amount, _) = timeToAmountAndUnit(workingCopy.time)
if (amount > 0) {
updateAlarm(workingCopy)
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
)
} }
@Composable @Composable
fun AddCustomReminderDialog( fun AddCustomReminderDialog(
alarm: Alarm?, viewState: ViewState,
updateAlarm: (Alarm) -> Unit, addAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
// Create working copy from alarm or use defaults val openDialog = viewState.showCustomDialog
var workingCopy by rememberSaveable { val time = rememberSaveable { mutableStateOf(15) }
mutableStateOf( val units = rememberSaveable { mutableStateOf(0) }
alarm ?: Alarm( val openRecurringDialog = rememberSaveable { mutableStateOf(false) }
time = -1 * 15 * TimeUnit.MINUTES.toMillis(1), val interval = rememberSaveable { mutableStateOf(0) }
type = TYPE_REL_END val recurringUnits = rememberSaveable { mutableStateOf(0) }
val repeat = rememberSaveable { mutableStateOf(0) }
if (openDialog) {
if (!openRecurringDialog.value) {
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddCustomReminder(
time,
units,
interval,
recurringUnits,
repeat,
showRecurring = {
openRecurringDialog.value = true
}
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it >= 0 }?.let { i ->
addAlarm(
Alarm(
time = -1 * i * units.millis,
type = TYPE_REL_END,
repeat = repeat.value,
interval = interval.value * recurringUnits.millis
)
)
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
) )
}
AddRepeatReminderDialog(
openDialog = openRecurringDialog,
initialInterval = interval.value,
initialUnits = recurringUnits.value,
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
} }
var showRecurringDialog by rememberSaveable { mutableStateOf(false) } }
if (!showRecurringDialog) { @Composable
fun AddRepeatReminderDialog(
openDialog: MutableState<Boolean>,
initialInterval: Int,
initialUnits: Int,
initialRepeat: Int,
selected: (Int, Int, Int) -> Unit,
) {
val interval = rememberSaveable { mutableStateOf(initialInterval) }
val units = rememberSaveable { mutableStateOf(initialUnits) }
val repeat = rememberSaveable { mutableStateOf(initialRepeat) }
val closeDialog = {
openDialog.value = false
}
if (openDialog.value) {
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { text = {
AddCustomReminder( AddRecurringReminder(
alarm = workingCopy, openDialog.value,
updateAlarm = { workingCopy = it }, interval,
showRecurring = { showRecurringDialog = true } units,
repeat,
) )
}, },
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
val (amount, _) = timeToAmountAndUnit(workingCopy.time) if (interval.value > 0 && repeat.value > 0) {
if (amount >= 0) { selected(interval.value, units.value, repeat.value)
updateAlarm(workingCopy) openDialog.value = false
closeDialog()
} }
}) })
}, },
@ -166,74 +207,19 @@ object AddReminderDialog {
) )
}, },
) )
} else {
interval.value = initialInterval.takeIf { it > 0 } ?: 15
units.value = initialUnits
repeat.value = initialRepeat.takeIf { it > 0 } ?: 4
} }
if (showRecurringDialog) {
AddRepeatReminderDialog(
alarm = workingCopy,
updateAlarm = { workingCopy = it },
closeDialog = { showRecurringDialog = false }
)
}
}
@Composable
fun AddRepeatReminderDialog(
alarm: Alarm,
updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit,
) {
// Create working copy with defaults if no recurrence set
var workingCopy by rememberSaveable {
mutableStateOf(
if (alarm.interval == 0L && alarm.repeat == 0) {
// Default to 15 minutes, 4 times
alarm.copy(
interval = 15 * TimeUnit.MINUTES.toMillis(1),
repeat = 4
)
} else {
alarm
}
)
}
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddRecurringReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (intervalAmount, _) = timeToAmountAndUnit(workingCopy.interval)
if (intervalAmount > 0 && workingCopy.repeat > 0) {
updateAlarm(workingCopy)
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
)
} }
@Composable @Composable
fun AddRandomReminder( fun AddRandomReminder(
alarm: Alarm, time: MutableState<Int>,
updateAlarm: (Alarm) -> Unit, units: MutableState<Int>,
) { ) {
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()
@ -242,27 +228,14 @@ 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(
value = amount, time,
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( RadioRow(index, option, time, units)
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)
} }
@ -270,19 +243,14 @@ object AddReminderDialog {
@Composable @Composable
fun AddCustomReminder( fun AddCustomReminder(
alarm: Alarm, time: MutableState<Int>,
updateAlarm: (Alarm) -> Unit, units: MutableState<Int>,
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()
@ -291,11 +259,7 @@ object AddReminderDialog {
CenteredH6(resId = R.string.custom_notification) CenteredH6(resId = R.string.custom_notification)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
value = amount, time,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = -1 * amt * unitIndexToMillis(selectedUnit)))
},
minValue = 0, minValue = 0,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -303,17 +267,7 @@ object AddReminderDialog {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow( RadioRow(index, option, time, units, R.string.alarm_before_due)
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
@ -334,11 +288,11 @@ object AddReminderDialog {
), ),
) )
} }
val repeating = alarm.repeat > 0 && intervalAmount > 0 val repeating = repeat.value > 0 && interval.value > 0
val text = if (repeating) { val text = if (repeating) {
LocalContext.current.resources.getRepeatString( LocalContext.current.resources.getRepeatString(
alarm.repeat, repeat.value,
alarm.interval interval.value * recurringUnits.millis
) )
} else { } else {
stringResource(id = R.string.repeat_option_does_not_repeat) stringResource(id = R.string.repeat_option_does_not_repeat)
@ -351,9 +305,11 @@ object AddReminderDialog {
.align(CenterVertically) .align(CenterVertically)
) )
if (repeating) { if (repeating) {
ClearButton(onClick = { ClearButton {
updateAlarm(alarm.copy(repeat = 0, interval = 0)) repeat.value = 0
}) interval.value = 0
recurringUnits.value = 0
}
} }
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(true, focusRequester)
@ -362,14 +318,12 @@ object AddReminderDialog {
@Composable @Composable
fun AddRecurringReminder( fun AddRecurringReminder(
alarm: Alarm, openDialog: Boolean,
updateAlarm: (Alarm) -> Unit, interval: MutableState<Int>,
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()
@ -378,40 +332,24 @@ 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(
value = intervalAmount, time = interval,
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( RadioRow(index, option, interval, units)
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(
value = alarm.repeat, time = 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,
alarm.repeat repeat.value
), ),
modifier = Modifier modifier = Modifier
.weight(0.5f) .weight(0.5f)
@ -419,7 +357,7 @@ object AddReminderDialog {
) )
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(openDialog, focusRequester)
} }
} }
@ -429,6 +367,14 @@ 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
@ -445,48 +391,25 @@ fun ShowKeyboard(visible: Boolean, focusRequester: FocusRequester) {
@Composable @Composable
fun OutlinedIntInput( fun OutlinedIntInput(
value: Int?, time: MutableState<Int>,
onValueChange: (Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
minValue: Int = 1, minValue: Int = 1,
autoSelect: Boolean = true, autoSelect: Boolean = true,
) { ) {
var textFieldValue by remember { val value = rememberSaveable(stateSaver = TextFieldValue.Saver) {
val text = time.value.toString()
mutableStateOf( mutableStateOf(
TextFieldValue( TextFieldValue(
text = value?.toString() ?: "", text = text,
selection = if (autoSelect) { selection = TextRange(0, if (autoSelect) text.length else 0)
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 = textFieldValue, value = value.value,
onValueChange = { onValueChange = {
textFieldValue = it.copy(text = it.text.filter { t -> t.isDigit() }) value.value = it.copy(text = it.text.filter { t -> t.isDigit() })
onValueChange(textFieldValue.text.toIntOrNull()) time.value = value.value.text.toIntOrNull() ?: 0
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier.padding(horizontal = 16.dp),
@ -496,7 +419,7 @@ fun OutlinedIntInput(
focusedBorderColor = MaterialTheme.colorScheme.onSurface, focusedBorderColor = MaterialTheme.colorScheme.onSurface,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurface, unfocusedBorderColor = MaterialTheme.colorScheme.onSurface,
), ),
isError = textFieldValue.text.toIntOrNull()?.let { it < minValue } ?: true, isError = value.value.text.toIntOrNull()?.let { it < minValue } ?: true,
) )
} }
@ -522,24 +445,23 @@ fun CenteredH6(text: String) {
fun RadioRow( fun RadioRow(
index: Int, index: Int,
option: Int, option: Int,
timeAmount: Int, time: MutableState<Int>,
unitIndex: Int, units: MutableState<Int>,
onUnitSelected: (Int) -> Unit,
formatString: Int? = null, formatString: Int? = null,
) { ) {
val optionString = LocalContext.current.resources.getQuantityString(option, timeAmount) val optionString = LocalContext.current.resources.getQuantityString(option, time.value)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onUnitSelected(index) } .clickable { units.value = index }
) { ) {
RadioButton( RadioButton(
selected = index == unitIndex, selected = index == units.value,
onClick = { onUnitSelected(index) }, onClick = { units.value = index },
modifier = Modifier.align(CenterVertically) modifier = Modifier.align(CenterVertically)
) )
BodyText( BodyText(
text = if (index == unitIndex) { text = if (index == units.value) {
formatString formatString
?.let { stringResource(id = formatString, optionString) } ?.let { stringResource(id = formatString, optionString) }
?: optionString ?: optionString
@ -584,14 +506,8 @@ fun AddAlarmDialog(
dismiss() dismiss()
return return
} }
TYPE_REL_END -> { // TODO: if replacing custom alarm show custom picker
if (viewState.replace.time < 0) { // TODO: prepopulate pickers with existing values
// Custom reminder (before due)
addCustom()
dismiss()
return
}
}
} }
} }
CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) { CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) {
@ -639,11 +555,11 @@ fun AddAlarmDialog(
fun AddCustomReminderOne() = fun AddCustomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
alarm = Alarm( time = remember { mutableStateOf(1) },
time = -1 * TimeUnit.MINUTES.toMillis(1), units = remember { mutableStateOf(0) },
type = TYPE_REL_END interval = remember { mutableStateOf(0) },
), recurringUnits = remember { mutableStateOf(0) },
updateAlarm = {}, repeat = remember { mutableStateOf(0) },
showRecurring = {}, showRecurring = {},
) )
} }
@ -655,11 +571,11 @@ fun AddCustomReminderOne() =
fun AddCustomReminder() = fun AddCustomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
alarm = Alarm( time = remember { mutableStateOf(15) },
time = -15 * TimeUnit.HOURS.toMillis(1), units = remember { mutableStateOf(1) },
type = TYPE_REL_END interval = remember { mutableStateOf(0) },
), recurringUnits = remember { mutableStateOf(0) },
updateAlarm = {}, repeat = remember { mutableStateOf(0) },
showRecurring = {}, showRecurring = {},
) )
} }
@ -671,13 +587,10 @@ fun AddCustomReminder() =
fun AddRepeatingReminderOne() = fun AddRepeatingReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
alarm = Alarm( openDialog = true,
time = -1 * TimeUnit.MINUTES.toMillis(1), interval = remember { mutableStateOf(1) },
type = TYPE_REL_END, units = remember { mutableStateOf(0) },
interval = TimeUnit.MINUTES.toMillis(1), repeat = remember { mutableStateOf(1) },
repeat = 1
),
updateAlarm = {},
) )
} }
@ -688,13 +601,10 @@ fun AddRepeatingReminderOne() =
fun AddRepeatingReminder() = fun AddRepeatingReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
alarm = Alarm( openDialog = true,
time = -15 * TimeUnit.HOURS.toMillis(1), interval = remember { mutableStateOf(15) },
type = TYPE_REL_END, units = remember { mutableStateOf(1) },
interval = 15 * TimeUnit.HOURS.toMillis(1), repeat = remember { mutableStateOf(4) },
repeat = 4
),
updateAlarm = {},
) )
} }
@ -705,11 +615,8 @@ fun AddRepeatingReminder() =
fun AddRandomReminderOne() = fun AddRandomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
alarm = Alarm( time = remember { mutableStateOf(1) },
time = TimeUnit.MINUTES.toMillis(1), units = remember { mutableStateOf(0) }
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }
@ -720,11 +627,8 @@ fun AddRandomReminderOne() =
fun AddRandomReminder() = fun AddRandomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
alarm = Alarm( time = remember { mutableStateOf(15) },
time = 15 * TimeUnit.HOURS.toMillis(1), units = remember { mutableStateOf(1) }
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }

@ -31,10 +31,7 @@ fun DeleteButton(
PromptAction( PromptAction(
showDialog = promptDelete, showDialog = promptDelete,
title = stringResource(id = R.string.delete_tag_confirmation, title), title = stringResource(id = R.string.delete_tag_confirmation, title),
onAction = { onAction = { scope.launch { onDelete() } },
scope.launch { onDelete() }
promptDelete = false
},
onCancel = { promptDelete = false }, onCancel = { promptDelete = false },
) )
} }

@ -1,9 +0,0 @@
package org.tasks.compose
import kotlinx.serialization.Serializable
@Serializable
object HomeDestination
@Serializable
data class AddAccountDestination(val showImport: Boolean)

@ -189,10 +189,8 @@ fun LazyItemScope.DraggableItem(
.zIndex(1f) .zIndex(1f)
.graphicsLayer { translationY = current } .graphicsLayer { translationY = current }
} else { } else {
Modifier.animateItem( Modifier.animateItemPlacement(
fadeInSpec = null, tween(easing = FastOutLinearInEasing)
fadeOutSpec = null,
placementSpec = tween(easing = FastOutLinearInEasing),
) )
} }
Box(modifier = modifier.then(draggingModifier)) { Box(modifier = modifier.then(draggingModifier)) {

@ -23,7 +23,6 @@ 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
@ -49,15 +48,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource 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.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -233,10 +229,10 @@ object FilterCondition {
) )
}, },
right = { right = {
val configuration = LocalConfiguration.current val context = LocalContext.current
val locale = remember(configuration) { val locale = remember {
ConfigurationCompat ConfigurationCompat
.getLocales(configuration) .getLocales(context.resources.configuration)
.get(0) .get(0)
?: Locale.getDefault() ?: Locale.getDefault()
} }
@ -386,31 +382,16 @@ 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( border = BorderStroke(1.dp, SolidColor(color.copy(alpha = 0.5f))),
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 = if (highlight) { containerColor = color.copy(alpha = 0.2f),
MaterialTheme.colorScheme.primary contentColor = MaterialTheme.colorScheme.onBackground),
} else { shape = RoundedCornerShape(Constants.HALF_KEYLINE)
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])
} }
@ -501,12 +482,7 @@ object FilterCondition {
contentDescription = null contentDescription = null
) )
}, },
keyboardOptions = KeyboardOptions( textStyle = MaterialTheme.typography.bodyMedium,
capitalization = KeyboardCapitalization.Sentences
),
textStyle = MaterialTheme.typography.bodyMedium.copy(
textDirection = TextDirection.Content
),
colors = Constants.textFieldColors(), colors = Constants.textFieldColors(),
) )
} }

@ -16,7 +16,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -34,10 +34,10 @@ fun OutlinedNumberInput(
onFocus: () -> Unit = {}, onFocus: () -> Unit = {},
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val configuration = LocalConfiguration.current val context = LocalContext.current
val locale = remember(configuration) { val locale = remember {
ConfigurationCompat ConfigurationCompat
.getLocales(configuration) .getLocales(context.resources.configuration)
.get(0) .get(0)
?: Locale.getDefault() ?: Locale.getDefault()
} }

@ -21,7 +21,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import org.tasks.R import org.tasks.R
import org.tasks.compose.Constants.TextButton import org.tasks.compose.Constants.TextButton
@ -109,9 +108,7 @@ object ShareInvite {
contentDescription = label contentDescription = label
) )
}, },
textStyle = MaterialTheme.typography.bodyLarge.copy( textStyle = MaterialTheme.typography.bodyLarge,
textDirection = TextDirection.Content
),
colors = textFieldColors(), colors = textFieldColors(),
) )
} }

@ -12,6 +12,7 @@ import org.tasks.kmp.org.tasks.time.DateStyle
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.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import org.tasks.time.startOfDay import org.tasks.time.startOfDay
@Composable @Composable
@ -31,7 +32,7 @@ fun StartDateChip(
) { ) {
startDate startDate
.takeIf { Task.hasDueTime(it) } .takeIf { Task.hasDueTime(it) }
?.let { getTimeString(it, context.is24HourFormat) } ?.let { getTimeString(currentTimeMillis(), context.is24HourFormat) }
} else { } else {
runBlocking { runBlocking {
getRelativeDateTime( getRelativeDateTime(

@ -1,348 +0,0 @@
package org.tasks.compose.accounts
import androidx.activity.compose.BackHandler
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Backup
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewFontScale
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import org.tasks.R
import org.tasks.sync.AddAccountDialog.Platform
import org.tasks.themes.TasksTheme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AddAccountScreen(
gettingStarted: Boolean,
hasTasksAccount: Boolean,
hasPro: Boolean,
onBack: () -> Unit,
signIn: (Platform) -> Unit,
openUrl: (Platform) -> Unit,
onImportBackup: () -> Unit,
) {
BackHandler {
if (!gettingStarted) {
onBack()
}
}
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(),
navigationIcon = {
if (!gettingStarted) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
}
},
title = {
Text(
text = if (gettingStarted) {
stringResource(R.string.sign_in)
} else {
stringResource(R.string.add_account)
}
)
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(vertical = 16.dp)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.spacedBy(16.dp),
maxItemsInEachRow = 5
) {
if (gettingStarted) {
ActionCard(
title = R.string.backup_BAc_import,
icon = Icons.Outlined.Backup,
onClick = onImportBackup,
isOutlined = true
)
ActionCard(
title = R.string.continue_without_sync,
icon = Icons.Outlined.CloudOff,
onClick = { signIn(Platform.LOCAL) },
isOutlined = true
)
}
if (!hasTasksAccount) {
AccountTypeCard(
title = R.string.tasks_org,
cost = R.string.cost_more_money,
icon = R.drawable.ic_round_icon,
onClick = { signIn(Platform.TASKS_ORG) }
)
}
AccountTypeCard(
title = R.string.microsoft,
cost = if (hasPro) null else R.string.cost_free,
icon = R.drawable.ic_microsoft_tasks,
onClick = { signIn(Platform.MICROSOFT) }
)
AccountTypeCard(
title = R.string.gtasks_GPr_header,
cost = if (hasPro) null else R.string.cost_free,
icon = R.drawable.ic_google,
onClick = { signIn(Platform.GOOGLE_TASKS) }
)
AccountTypeCard(
title = R.string.davx5,
cost = if (hasPro) null else R.string.cost_money,
icon = R.drawable.ic_davx5_icon_green_bg,
onClick = { openUrl(Platform.DAVX5) }
)
AccountTypeCard(
title = R.string.caldav,
cost = if (hasPro) null else R.string.cost_money,
icon = R.drawable.ic_webdav_logo,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = .8f),
onClick = { signIn(Platform.CALDAV) }
)
AccountTypeCard(
title = R.string.etesync,
cost = if (hasPro) null else R.string.cost_money,
icon = R.drawable.ic_etesync,
onClick = { signIn(Platform.ETESYNC) }
)
AccountTypeCard(
title = R.string.decsync,
cost = if (hasPro) null else R.string.cost_money,
icon = R.drawable.ic_decsync,
onClick = { openUrl(Platform.DECSYNC_CC) }
)
if (gettingStarted) {
ActionCard(
title = R.string.help_me_choose,
icon = Icons.AutoMirrored.Outlined.HelpOutline,
onClick = { openUrl(Platform.LOCAL) },
isOutlined = true
)
}
}
}
}
}
@Composable
fun AccountTypeCard(
@StringRes title: Int,
@StringRes cost: Int? = null,
@DrawableRes icon: Int,
tint: Color? = null,
onClick: () -> Unit,
) {
Card(
modifier = Modifier
.width(108.dp),
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = icon),
contentDescription = stringResource(id = title),
tint = tint ?: Color.Unspecified,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = buildAnnotatedString {
append(stringResource(id = title))
cost?.let {
append("\n")
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.labelSmall.fontSize
)
) {
append(stringResource(id = it))
}
}
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
minLines = 3,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun ActionCard(
@StringRes title: Int,
icon: ImageVector,
onClick: () -> Unit,
isOutlined: Boolean = false
) {
if (isOutlined) {
OutlinedCard(
modifier = Modifier
.width(108.dp),
shape = MaterialTheme.shapes.medium,
onClick = onClick
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = stringResource(id = title),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
minLines = 3,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
} else {
Card(
modifier = Modifier
.width(150.dp),
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = stringResource(id = title),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@PreviewLightDark
@PreviewScreenSizes
@PreviewFontScale
@Composable
fun GettingStartedPreview() {
TasksTheme {
AddAccountScreen(
gettingStarted = true,
hasTasksAccount = false,
hasPro = false,
onBack = {},
signIn = {},
openUrl = {},
onImportBackup = {},
)
}
}
@PreviewLightDark
@Composable
fun AddAccountPreview() {
TasksTheme {
AddAccountScreen(
gettingStarted = false,
hasTasksAccount = false,
hasPro = false,
onBack = {},
signIn = {},
openUrl = {},
onImportBackup = {},
)
}
}

@ -1,32 +0,0 @@
package org.tasks.compose.accounts
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.data.dao.CaldavDao
import org.tasks.data.newLocalAccount
import org.tasks.extensions.Context.openUri
import org.tasks.sync.AddAccountDialog
import javax.inject.Inject
@HiltViewModel
class AddAccountViewModel @Inject constructor(
private val caldavDao: CaldavDao,
) : ViewModel() {
fun createLocalAccount() = viewModelScope.launch {
caldavDao.newLocalAccount()
}
fun openUrl(context: Context, platform: AddAccountDialog.Platform) {
val url = when (platform) {
AddAccountDialog.Platform.DAVX5 -> R.string.url_davx5
AddAccountDialog.Platform.DECSYNC_CC -> R.string.url_decsync
AddAccountDialog.Platform.LOCAL -> R.string.help_url_sync
else -> return
}
context.openUri(context.getString(url))
}
}

@ -106,27 +106,23 @@ 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) } )
)
}
}, },
) )
} }

@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import com.todoroo.andlib.utility.AndroidUtilities
import org.tasks.R import org.tasks.R
import org.tasks.dialogs.Linkify import org.tasks.dialogs.Linkify
import org.tasks.markdown.MarkdownProvider import org.tasks.markdown.MarkdownProvider
@ -92,7 +93,10 @@ fun EditTextView(
} }
setBackgroundColor(context.getColor(android.R.color.transparent)) setBackgroundColor(context.getColor(android.R.color.transparent))
importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO textAlignment = View.TEXT_ALIGNMENT_VIEW_START
if (AndroidUtilities.atLeastOreo()) {
importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO
}
freezesText = true freezesText = true
setHorizontallyScrolling(false) setHorizontallyScrolling(false)
setHint(hint) setHint(hint)

@ -31,7 +31,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.tasks.compose.CheckBox import org.tasks.compose.CheckBox
@ -171,7 +170,6 @@ fun NewSubtaskRow(
.padding(top = 12.dp), .padding(top = 12.dp),
textStyle = MaterialTheme.typography.bodyLarge.copy( textStyle = MaterialTheme.typography.bodyLarge.copy(
textDecoration = if (subtask.isCompleted) TextDecoration.LineThrough else TextDecoration.None, textDecoration = if (subtask.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
textDirection = TextDirection.Content,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
), ),
keyboardOptions = KeyboardOptions.Default.copy( keyboardOptions = KeyboardOptions.Default.copy(

@ -1,316 +0,0 @@
package org.tasks.compose.home
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat.getParcelableExtra
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.hilt.navigation.compose.hiltViewModel
import com.todoroo.astrid.activity.MainActivity.Companion.OPEN_FILTER
import com.todoroo.astrid.activity.MainActivityViewModel
import com.todoroo.astrid.activity.TaskEditFragment
import com.todoroo.astrid.activity.TaskEditFragment.Companion.EXTRA_TASK
import com.todoroo.astrid.activity.TaskListFragment
import com.todoroo.astrid.activity.TaskListFragment.Companion.EXTRA_FILTER
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.TasksApplication
import org.tasks.activities.TagSettingsActivity
import org.tasks.billing.PurchaseActivity
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
import org.tasks.compose.drawer.DrawerAction
import org.tasks.compose.drawer.DrawerItem
import org.tasks.compose.drawer.MenuSearchBar
import org.tasks.compose.drawer.TaskListDrawer
import org.tasks.data.listSettingsClass
import org.tasks.extensions.Context.openUri
import org.tasks.filters.Filter
import org.tasks.filters.FilterProvider
import org.tasks.filters.FilterProvider.Companion.REQUEST_NEW_LIST
import org.tasks.filters.FilterProvider.Companion.REQUEST_NEW_PLACE
import org.tasks.filters.FilterProvider.Companion.REQUEST_NEW_TAGS
import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.kmp.org.tasks.compose.TouchSlopMultiplier
import org.tasks.kmp.org.tasks.compose.rememberImeState
import org.tasks.location.LocationPickerActivity
import org.tasks.preferences.HelpAndFeedback
import org.tasks.preferences.MainPreferences
import timber.log.Timber
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun HomeScreen(
viewModel: MainActivityViewModel = hiltViewModel(LocalActivity.current as ComponentActivity),
state: MainActivityViewModel.State,
drawerState: DrawerState,
showNewFilterDialog: () -> Unit,
navigator: ThreePaneScaffoldNavigator<Any>,
) {
val currentWindowInsets = WindowInsets.systemBars.asPaddingValues()
val windowInsets = remember { mutableStateOf(currentWindowInsets) }
val keyboard = LocalSoftwareKeyboardController.current
val newList =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data
?.let { getParcelableExtra(it, OPEN_FILTER, Filter::class.java) }
?.let { viewModel.setFilter(it) }
}
}
LaunchedEffect(currentWindowInsets) {
Timber.d("insets: $currentWindowInsets")
if (currentWindowInsets.calculateTopPadding() != 0.dp || currentWindowInsets.calculateBottomPadding() != 0.dp) {
windowInsets.value = currentWindowInsets
}
}
val isListVisible =
navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
val isDetailVisible =
navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
TouchSlopMultiplier {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = isListVisible,
drawerContent = {
ModalDrawerSheet(
drawerState = drawerState,
windowInsets = WindowInsets(0, 0, 0, 0),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Box(modifier = Modifier.fillMaxSize()) {
TaskListDrawer(
arrangement = if (state.menuQuery.isBlank()) Arrangement.Top else Arrangement.Bottom,
filters = if (state.menuQuery.isNotEmpty()) state.searchItems else state.drawerItems,
onClick = {
when (it) {
is DrawerItem.Filter -> {
viewModel.setFilter(it.filter)
scope.launch {
drawerState.close()
keyboard?.hide()
}
}
is DrawerItem.Header -> {
viewModel.toggleCollapsed(it.header)
}
}
},
onAddClick = {
scope.launch {
drawerState.close()
when (it.header.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
showNewFilterDialog()
REQUEST_NEW_PLACE ->
newList.launch(Intent(context, LocationPickerActivity::class.java))
REQUEST_NEW_TAGS ->
newList.launch(Intent(context, TagSettingsActivity::class.java))
REQUEST_NEW_LIST ->
when (it.header.subheaderType) {
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS ->
viewModel
.getAccount(it.header.id.toLong())
?.let {
newList.launch(
Intent(context, it.listSettingsClass())
.putExtra(EXTRA_CALDAV_ACCOUNT, it)
)
}
else -> {}
}
else -> Timber.e("Unhandled request code: $it")
}
}
},
onErrorClick = {
context.startActivity(Intent(context, MainPreferences::class.java))
},
searchBar = {
MenuSearchBar(
begForMoney = state.begForMoney,
onDrawerAction = {
scope.launch {
drawerState.close()
when (it) {
DrawerAction.PURCHASE ->
if (TasksApplication.IS_GENERIC)
context.openUri(R.string.url_donate)
else
context.startActivity(
Intent(
context,
PurchaseActivity::class.java
)
)
DrawerAction.HELP_AND_FEEDBACK ->
context.startActivity(
Intent(
context,
HelpAndFeedback::class.java
)
)
}
}
},
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
},
)
SystemBarScrim(
modifier = Modifier
.windowInsetsTopHeight(WindowInsets.systemBars)
.align(Alignment.TopCenter)
)
SystemBarScrim(
modifier = Modifier
.windowInsetsBottomHeight(WindowInsets.systemBars)
.align(Alignment.BottomCenter),
)
}
}
}
) {
Box(modifier = Modifier.fillMaxSize()) {
val scope = rememberCoroutineScope()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
key (state.filter) {
val fragment = remember { mutableStateOf<TaskListFragment?>(null) }
val keyboardOpen = rememberImeState()
AndroidFragment<TaskListFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.filter) {
Bundle()
.apply { putParcelable(EXTRA_FILTER, state.filter) }
},
modifier = Modifier
.fillMaxSize()
.imePadding(),
) { tlf ->
fragment.value = tlf
tlf.applyInsets(windowInsets.value)
tlf.setNavigationClickListener {
scope.launch { drawerState.open() }
}
}
LaunchedEffect(fragment, windowInsets, keyboardOpen.value) {
fragment.value?.applyInsets(
if (keyboardOpen.value) {
PaddingValues(
top = windowInsets.value.calculateTopPadding(),
)
} else {
windowInsets.value
}
)
}
}
},
detailPane = {
val direction = LocalLayoutDirection.current
Box(
modifier = Modifier
.fillMaxSize()
.padding(
top = windowInsets.value.calculateTopPadding(),
start = windowInsets.value.calculateStartPadding(direction),
end = windowInsets.value.calculateEndPadding(direction),
bottom = if (rememberImeState().value)
WindowInsets.ime.asPaddingValues().calculateBottomPadding()
else
windowInsets.value.calculateBottomPadding()
),
contentAlignment = Alignment.Center,
) {
if (state.task == null) {
if (isListVisible && isDetailVisible) {
Icon(
painter = painterResource(org.tasks.kmp.R.drawable.ic_launcher_no_shadow_foreground),
contentDescription = null,
modifier = Modifier.size(192.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
key(state.task) {
AndroidFragment<TaskEditFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.task) {
Bundle()
.apply { putParcelable(EXTRA_TASK, state.task) }
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
},
)
SystemBarScrim(
modifier = Modifier
.windowInsetsTopHeight(WindowInsets.systemBars)
.align(Alignment.TopCenter),
)
}
}
}
}

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

Loading…
Cancel
Save