Compare commits

..

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

@ -20,16 +20,16 @@ jobs:
- name: Decode Keystore
run: |
echo ${{ secrets.KEY_STORE }} | base64 -di > "${RUNNER_TEMP}"/keystore.jks
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
@ -44,7 +44,7 @@ jobs:
GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }}
run: bundle exec fastlane bundle
- name: Upload artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: release
path: |

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

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

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

@ -1 +1 @@
3.4.7
3.3.6

@ -1,120 +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)

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

@ -5,31 +5,29 @@ GEM
base64
nkf
rexml
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1122.0)
aws-sdk-core (3.226.1)
aws-eventstream (1.3.2)
aws-partitions (1.1067.0)
aws-sdk-core (3.220.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.191.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-s3 (1.182.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@ -58,10 +56,10 @@ GEM
faraday (>= 0.8.0)
http-cookie (~> 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-httpclient (1.0.1)
faraday-multipart (1.1.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@ -71,7 +69,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
fastlane (2.227.2)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@ -158,23 +156,22 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.12.2)
jwt (2.10.2)
json (2.10.2)
jwt (2.10.1)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.0)
public_suffix (5.1.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@ -185,7 +182,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.20.0)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
@ -221,8 +218,7 @@ PLATFORMS
ruby
DEPENDENCIES
abbrev
fastlane
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)
[![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
@ -23,6 +23,8 @@ Contributions are always welcome! Whether translations, code changes, bug report
### 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).

3
app/proguard.pro vendored

@ -26,8 +26,6 @@
-dontwarn net.fortuna.ical4j.model.**
-dontwarn org.codehaus.groovy.**
-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 at.bitfire.** { *; } # all DAVdroid code is required
@ -59,6 +57,5 @@
-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>; }

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

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

@ -1,13 +1,13 @@
package org.tasks.caldav
import org.tasks.data.UUIDHelper
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.tasks.data.UUIDHelper
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_OWNCLOUD
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_UNKNOWN
@ -36,7 +36,7 @@ class ServerDetectionTest : CaldavTest() {
sync()
assertEquals(SERVER_NEXTCLOUD, loadAccount().serverType)
assertEquals(SERVER_OWNCLOUD, loadAccount().serverType)
}
@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())
}
}
}

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

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

@ -159,7 +159,6 @@
</queries>
<application
android:pageSizeCompat="enabled"
android:allowBackup="true"
android:backupAgent="org.tasks.backup.TasksBackupAgent"
android:backupInForeground="true"
@ -387,13 +386,6 @@
android:resource="@xml/file_provider_paths"/>
</provider>
<provider
android:name=".widget.WidgetIconProvider"
android:authorities="${applicationId}.widgeticons"
android:exported="true"
android:grantUriPermissions="true"
tools:ignore="ExportedContentProvider" />
<receiver
android:name="org.dmfs.provider.tasks.TaskProviderBroadcastReceiver"
tools:node="remove"/>
@ -638,7 +630,7 @@
android:name="com.todoroo.astrid.activity.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Tasks"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

@ -75,14 +75,6 @@ object AndroidUtilities {
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() {
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.os.Bundle;
import android.view.MenuItem;
import android.view.ViewGroup;
import androidx.activity.EdgeToEdge;
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.RecyclerView;
@ -88,24 +84,12 @@ public class BeastModePreferences extends ThemedInjectingAppCompatActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
BeastModePrefActivityBinding binding = BeastModePrefActivityBinding.inflate(getLayoutInflater());
Toolbar toolbar = binding.toolbar.toolbar;
RecyclerView recyclerView = binding.recyclerView;
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(
getDrawable(R.drawable.ic_outline_arrow_back_24px));
toolbar.setNavigationOnClickListener(v -> finish());

@ -24,7 +24,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
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.rememberDrawerState
import androidx.compose.runtime.LaunchedEffect
@ -123,11 +123,10 @@ class MainActivity : AppCompatActivity() {
lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT
),
navigationBarStyle = if (theme.themeBase.isDarkTheme(this)) {
SystemBarStyle.dark(Color.TRANSPARENT)
} else {
SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
}
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT
)
)
setContent {
@ -242,7 +241,7 @@ class MainActivity : AppCompatActivity() {
}
)
val navigator = rememberListDetailPaneScaffoldNavigator(
calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(
calculatePaneScaffoldDirective(
windowAdaptiveInfo = currentWindowAdaptiveInfo(),
).copy(
horizontalPartitionSpacerSize = 0.dp,

@ -39,7 +39,6 @@ import org.tasks.filters.CaldavFilter
import org.tasks.filters.Filter
import org.tasks.filters.FilterProvider
import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.filters.SearchFilter
import org.tasks.filters.getIcon
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.TasksPreferences
@ -91,7 +90,8 @@ class MainActivityViewModel @Inject constructor(
private val refreshReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
LocalBroadcastManager.REFRESH -> _updateFilters.update { currentTimeMillis() }
LocalBroadcastManager.REFRESH,
LocalBroadcastManager.REFRESH_LIST -> _updateFilters.update { currentTimeMillis() }
}
}
}
@ -114,9 +114,12 @@ class MainActivityViewModel @Inject constructor(
)
}
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) {
@ -215,12 +218,12 @@ class MainActivityViewModel @Inject constructor(
when (subheader.subheaderType) {
NavigationDrawerSubheader.SubheaderType.PREFERENCE -> {
tasksPreferences.set(booleanPreferencesKey(subheader.id), collapsed)
localBroadcastManager.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS -> {
caldavDao.setCollapsed(subheader.id, collapsed)
localBroadcastManager.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
}
}
@ -235,8 +238,4 @@ class MainActivityViewModel @Inject constructor(
}
suspend fun getAccount(id: Long) = caldavDao.getAccount(id)
fun openLastViewedFilter() = viewModelScope.launch {
setFilter(defaultFilterProvider.getLastViewedFilter())
}
}

@ -22,6 +22,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LAYOUT_DIRECTION_LTR
import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts
@ -284,7 +285,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
override fun handleOnBackPressed() {
if ((mainViewModel.state.value.filter as? SearchFilter)?.query?.isNotBlank() == true) {
lifecycleScope.launch {
mainViewModel.openLastViewedFilter()
mainViewModel.resetFilter()
}
if (search.isActionViewExpanded) {
search.collapseActionView()
@ -337,9 +338,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
right = endInset,
)
binding.bottomAppBar.updatePadding(bottom = bottomInset)
val scrimLayoutParams = binding.systemBarScrim.layoutParams
scrimLayoutParams.height = bottomInset
binding.systemBarScrim.layoutParams = scrimLayoutParams
(binding.fab.layoutParams as MarginLayoutParams).bottomMargin = bottomInset / 2
}
@OptIn(ExperimentalPermissionsApi::class)
@ -366,14 +365,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
listViewModel.setFilter(filter)
(recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false
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 {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
listViewModel.updateBannerState()
@ -403,11 +394,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
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) {
backgroundTintList = ColorStateList.valueOf(themeColor.primaryColor)
imageTintList = ColorStateList.valueOf(themeColor.colorOnPrimary)
@ -686,7 +672,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
} else {
dialogBuilder
.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) { _, _ ->
lifecycleScope.launch {
listViewModel.markDeleted(tasks)
@ -782,12 +768,10 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
colorProvider.getPriorityColor(3))
}
@SuppressLint("NotifyDataSetChanged")
override fun onResume() {
super.onResume()
listViewModel.invalidate()
localBroadcastManager.registerTaskCompletedReceiver(repeatConfirmationReceiver)
recyclerAdapter?.notifyDataSetChanged() // force rebind to update timestamps (hidden/overdue)
}
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.service.TaskMover
import com.todoroo.astrid.subtasks.SubtasksFilterUpdater
import org.tasks.LocalBroadcastManager
import org.tasks.Strings.isNullOrEmpty
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.TaskContainer
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -24,9 +24,9 @@ class AstridTaskAdapter internal constructor(
googleTaskDao: GoogleTaskDao,
caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
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>>())
@ -56,7 +56,7 @@ class AstridTaskAdapter internal constructor(
for (i in 0 until abs(delta)) {
updater.indent(list, filter, targetTaskId, delta)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
} catch (e: Exception) {
Timber.e(e)
}

@ -6,7 +6,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.activities.TagSettingsActivity
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
import org.tasks.data.dao.CaldavDao
@ -30,7 +30,7 @@ class SubheaderClickHandler @Inject constructor(
private val activity: Activity,
private val tasksPreferences: TasksPreferences,
private val caldavDao: CaldavDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
): SubheaderViewHolder.ClickHandler {
override fun onClick(subheader: NavigationDrawerSubheader) {
(activity as AppCompatActivity).lifecycleScope.launch {
@ -40,7 +40,7 @@ class SubheaderClickHandler @Inject constructor(
CALDAV,
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.service.TaskMover
import org.tasks.BuildConfig
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.TaskContainer
import org.tasks.data.createDueDate
import org.tasks.data.createHideUntil
@ -31,7 +31,7 @@ open class TaskAdapter(
private val googleTaskDao: GoogleTaskDao,
private val caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskMover: TaskMover,
) {
private val selected = HashSet<Long>()
@ -296,7 +296,7 @@ open class TaskAdapter(
taskDao.setOrder(task.id, task.task.order)
taskDao.setParent(newParentId, listOf(task.id))
taskDao.touch(task.id)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
protected suspend fun moveGoogleTask(from: Int, to: Int, indent: Int) {
@ -375,7 +375,7 @@ open class TaskAdapter(
}
}
taskDao.touch(task.id)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
if (BuildConfig.DEBUG) {
googleTaskDao.validateSorting(task.caldav!!)
}
@ -407,7 +407,7 @@ open class TaskAdapter(
newPosition = newPosition,
)
taskDao.touch(task.id)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
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) =
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(
baseline.plus((reminderPeriod * multiplier).toLong()),
task.reminderLast
.coerceAtLeast(task.creationDate)
.plus((reminderPeriod * (0.85f + 0.3f * random.nextFloat())).toLong()),
task.hideUntil
)
} else {

@ -5,7 +5,7 @@
*/
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.TaskDao
import org.tasks.data.db.DbUtils
@ -28,7 +28,7 @@ import javax.inject.Inject
class AlarmService @Inject constructor(
private val alarmDao: AlarmDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val notificationManager: NotificationManager,
private val workManager: WorkManager,
private val alarmCalculator: AlarmCalculator,
@ -54,7 +54,7 @@ class AlarmService @Inject constructor(
changed = true
}
if (changed) {
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
return changed
}

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

@ -11,7 +11,6 @@ import android.content.Context
import android.net.Uri
import android.provider.CalendarContract
import android.text.format.Time
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
@ -20,7 +19,7 @@ import org.tasks.data.dao.TaskDao
import org.tasks.data.entity.Task
import org.tasks.preferences.PermissionChecker
import org.tasks.preferences.Preferences
import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import org.tasks.time.ONE_HOUR
import timber.log.Timber
import java.util.TimeZone
@ -31,8 +30,8 @@ class GCalHelper @Inject constructor(
private val taskDao: TaskDao,
private val preferences: Preferences,
private val permissionChecker: PermissionChecker,
private val calendarEventProvider: CalendarEventProvider,
) {
private val calendarEventProvider: CalendarEventProvider) {
private val cr: ContentResolver = context.contentResolver
private suspend fun getTaskEventUri(task: Task) =
@ -110,7 +109,7 @@ class GCalHelper @Inject constructor(
})
updateValues.put(CalendarContract.Events.DESCRIPTION, task.notes)
createStartAndEndDate(task, updateValues)
cr.update(uri.toUri(), updateValues, null, null)
cr.update(Uri.parse(uri), updateValues, null, null)
} catch (e: Exception) {
Timber.e(e, "Failed to update calendar: %s [%s]", uri, task)
}
@ -118,10 +117,10 @@ class GCalHelper @Inject constructor(
suspend fun rescheduleRepeatingTask(task: Task) {
val taskUri = getTaskEventUri(task)
if (taskUri.isNullOrBlank()) {
if (isNullOrEmpty(taskUri)) {
return
}
val eventUri = taskUri.toUri()
val eventUri = Uri.parse(taskUri)
val event = calendarEventProvider.getEvent(eventUri)
if (event == null) {
task.calendarURI = ""
@ -135,6 +134,11 @@ class GCalHelper @Inject constructor(
private fun createStartAndEndDate(task: Task, values: ContentValues) {
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.hasDueTime()) {
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.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.EVENT_TIMEZONE, TimeZone.getDefault().id)
} else {
val utcMidnight = DateTime(dueDate).toUTC().startOfDay()
values.put(CalendarContract.Events.DTSTART, utcMidnight.millis)
values.put(CalendarContract.Events.DTEND, utcMidnight.plusDays(1).millis)
values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDate)
values.put(CalendarContract.Events.DTEND, tzCorrectedDueDate)
values.put(CalendarContract.Events.ALL_DAY, "1")
values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC)
}
} 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 {

@ -7,7 +7,7 @@ package com.todoroo.astrid.gtasks
import com.google.api.services.tasks.model.TaskList
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.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar
@ -16,7 +16,7 @@ import javax.inject.Inject
class GtasksListService @Inject constructor(
private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
) {
/**
@ -55,6 +55,6 @@ class GtasksListService @Inject constructor(
for (listId in previousLists) {
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.TaskLists
import org.tasks.googleapis.BaseInvoker
import timber.log.Timber
import java.io.IOException
/**
@ -44,30 +43,21 @@ class GtasksInvoker(
@Throws(IOException::class)
suspend fun getAllPositions(
listId: String?,
pageToken: String?,
): com.google.api.services.tasks.model.Tasks? =
execute(
service!!
.tasks()
.list(listId)
.setMaxResults(100)
.setShowDeleted(false)
.setShowHidden(false)
.setPageToken(pageToken)
.setFields("items(id,parent,position),nextPageToken")
)
listId: String?, pageToken: String?): com.google.api.services.tasks.model.Tasks? =
execute(
service!!
.tasks()
.list(listId)
.setMaxResults(100)
.setShowDeleted(false)
.setShowHidden(false)
.setPageToken(pageToken)
.setFields("items(id,parent,position),nextPageToken"))
@Throws(IOException::class)
suspend fun createGtask(
listId: String?,
task: Task?,
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))
}
listId: String?, task: Task?, parent: String?, previous: String?): Task? =
execute(service!!.tasks().insert(listId, task).setParent(parent).setPrevious(previous))
@Throws(IOException::class)
suspend fun updateGtask(listId: String?, task: Task) =
@ -75,26 +65,19 @@ class GtasksInvoker(
@Throws(IOException::class)
suspend fun moveGtask(
listId: String?,
taskId: String?,
parentId: String?,
previousId: String?,
): Task? {
Timber.d("moveGtask(listId=$listId, taskId=$taskId, parentId=$parentId, previousId=$previousId)")
return execute(
service!!
.tasks()
.move(listId, taskId)
.setParent(parentId)
.setPrevious(previousId)
)
}
listId: String?, taskId: String?, parentId: String?, previousId: String?): Task? =
execute(
service!!
.tasks()
.move(listId, taskId)
.setParent(parentId)
.setPrevious(previousId))
@Throws(IOException::class)
suspend fun deleteGtaskList(listId: String?) {
try {
execute(service!!.tasklists().delete(listId))
} catch (_: HttpNotFoundException) {
} catch (ignored: HttpNotFoundException) {
}
}
@ -108,10 +91,9 @@ class GtasksInvoker(
@Throws(IOException::class)
suspend fun deleteGtask(listId: String?, taskId: String?) {
Timber.d("deleteGtask(listId=$listId, taskId=$taskId)")
try {
execute(service!!.tasks().delete(listId, taskId))
} catch (_: HttpNotFoundException) {
} catch (ignored: HttpNotFoundException) {
}
}
}

@ -7,7 +7,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.DeletionDao
import org.tasks.data.dao.LocationDao
@ -28,7 +28,7 @@ class TaskDeleter @Inject constructor(
@ApplicationContext private val context: Context,
private val deletionDao: DeletionDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val syncAdapters: SyncAdapters,
private val vtodoCache: VtodoCache,
private val notificationManager: NotificationManager,
@ -50,7 +50,7 @@ class TaskDeleter @Inject constructor(
cleanup = { cleanup(it) }
)
syncAdapters.sync()
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
taskDao.fetch(ids)
}
@ -63,7 +63,7 @@ class TaskDeleter @Inject constructor(
ids = tasks,
cleanup = { cleanup(it) }
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
suspend fun delete(list: CaldavCalendar) {
@ -72,7 +72,7 @@ class TaskDeleter @Inject constructor(
caldavCalendar = list,
cleanup = { cleanup(it) }
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
suspend fun delete(account: CaldavAccount) {
@ -81,7 +81,7 @@ class TaskDeleter @Inject constructor(
caldavAccount = account,
cleanup = { cleanup(it) }
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
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.gcal.GCalHelper
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -24,7 +24,7 @@ import javax.inject.Inject
class TaskDuplicator @Inject constructor(
private val gcalHelper: GCalHelper,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val tagDao: TagDao,
private val tagDataDao: TagDataDao,
private val googleTaskDao: GoogleTaskDao,
@ -44,7 +44,7 @@ class TaskDuplicator @Inject constructor(
.let { taskDao.fetch(it) }
.filterNot { it.readOnly }
.map { clone(it, it.parent) }
.also { refreshBroadcaster.broadcastRefresh() }
.also { localBroadcastManager.broadcastRefresh() }
}
private suspend fun clone(task: Task, parentId: Long): Task {

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

@ -3,9 +3,6 @@ package com.todoroo.astrid.service
import android.content.Context
import android.net.Uri
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.ListMultimap
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.TagData
import org.tasks.filters.CaldavFilter
import org.tasks.jobs.UpgradeIconSyncWork
import org.tasks.jobs.networkConstraints
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
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)
} else {
setInstallDetails(to)
@ -421,7 +407,6 @@ class Upgrader @Inject constructor(
const val V12_8 = 120800
const val V14_5_4 = 140516
const val V14_6_1 = 140602
const val V14_8 = 140800
@JvmStatic
fun getAndroidColor(context: Context, index: Int): Int {

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

@ -6,7 +6,6 @@ import android.app.ApplicationExitInfo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
@ -18,7 +17,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import androidx.work.Configuration
import com.mikepenz.iconics.Iconics
import com.todoroo.andlib.utility.AndroidUtilities.atLeastAndroid15
import com.todoroo.andlib.utility.AndroidUtilities.atLeastR
import com.todoroo.astrid.service.Upgrader
import dagger.Lazy
@ -104,17 +102,11 @@ class TasksApplication : Application(), Configuration.Provider {
Timber.i("Astrid Startup. %s => %s", lastVersion, currentVersion)
if (atLeastR()) {
scope.launch {
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val exitReasons = activityManager.getHistoricalProcessExitReasons(null, 0, 1)
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
if (lastVersion != currentVersion) {

@ -14,7 +14,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.pm.ShortcutInfoCompat
@ -30,20 +29,21 @@ import com.todoroo.andlib.utility.AndroidUtilities.atLeastS
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.billing.PurchaseActivity
import org.tasks.compose.DeleteButton
import org.tasks.compose.IconPickerActivity.Companion.launchIconPicker
import org.tasks.compose.IconPickerActivity.Companion.registerForIconPickerResult
import org.tasks.compose.settings.ListSettingsContent
import org.tasks.compose.settings.ListSettingsScaffold
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.filters.Filter
import org.tasks.icons.OutlinedGoogleMaterial
import org.tasks.intents.TaskIntents
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.themes.ColorProvider
import org.tasks.themes.Theme
import org.tasks.themes.contentColorFor
import org.tasks.widget.RequestPinWidgetReceiver
@ -53,12 +53,10 @@ import org.tasks.widget.TasksWidget
import javax.inject.Inject
abstract class BaseListSettingsActivity : AppCompatActivity() {
abstract class BaseListSettingsActivity : AppCompatActivity(), ColorPalettePicker.ColorPickedCallback, ColorWheelPicker.ColorPickedCallback {
@Inject lateinit var tasksTheme: Theme
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var firebase: Firebase
@Inject lateinit var inventory: Inventory
@Inject lateinit var colorProvider: ColorProvider
protected val baseViewModel: BaseListSettingsViewModel by viewModels()
@ -90,6 +88,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 ->
baseViewModel.setIcon(selected)
}
@ -98,6 +101,10 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
launcher.launchIconPicker(this, baseViewModel.icon)
}
override fun onColorPicked(color: Int) {
baseViewModel.setColor(color)
}
protected open fun promptDelete() { baseViewModel.promptDelete(true) }
/** Standard @Compose view content for descendants. Caller must wrap it to TasksTheme{} */
@ -125,9 +132,7 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
fab = fab,
) {
ListSettingsContent(
hasPro = remember { inventory.purchasedThemes() },
color = viewState.color,
colors = remember { colorProvider.getThemeColors() },
icon = viewState.icon ?: defaultIcon,
text = viewState.title,
error = viewState.error,
@ -137,16 +142,12 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
baseViewModel.setTitle(it)
baseViewModel.setError("")
},
setColor = { baseViewModel.setColor(it) },
pickColor = { showThemePicker() },
clearColor = { onColorPicked(0) },
pickIcon = { showIconPicker() },
addShortcutToHome = { createShortcut(color) },
addWidgetToHome = { createWidget() },
extensionContent = extensionContent,
purchase = {
startActivity(
Intent(this@BaseListSettingsActivity, PurchaseActivity::class.java)
)
},
)
}
}
@ -218,6 +219,8 @@ abstract class BaseListSettingsActivity : AppCompatActivity() {
}
companion object {
private const val FRAG_TAG_COLOR_PICKER = "frag_tag_color_picker"
fun createShortcutIcon(context: Context, backgroundColor: Color): IconCompat {
val size = context.resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)

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

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

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

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

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

@ -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)
private suspend fun doTasksExport(os: OutputStream?, taskIds: List<Long>) = withContext(Dispatchers.IO) {
val writer = os!!.bufferedWriter()
@ -160,7 +146,11 @@ class TasksJsonExporter @Inject constructor(
write("caldavCalendars", caldavDao.getCalendars())
write("taskListMetadata", taskListMetadataDao.getAll())
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("}")
}
@ -169,14 +159,6 @@ class TasksJsonExporter @Inject constructor(
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 {
context?.toast(
R.string.export_toast,

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

@ -8,16 +8,10 @@ import android.os.Bundle
import android.text.TextUtils
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources
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.getValue
import androidx.compose.runtime.mutableStateOf
@ -76,18 +70,8 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityCaldavAccountSettingsBinding.inflate(layoutInflater)
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)
serverType = mutableStateOf(
savedInstanceState?.getInt(EXTRA_SERVER_TYPE, SERVER_UNKNOWN)

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

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

@ -12,8 +12,6 @@ import org.tasks.data.UUIDHelper
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.PrincipalDao
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.Companion.ACCESS_READ_WRITE
import org.tasks.data.entity.CaldavCalendar.Companion.INVITE_UNKNOWN
@ -39,7 +37,7 @@ class CaldavCalendarViewModel @Inject constructor(
): CaldavCalendar? =
doRequest {
val url = withContext(Dispatchers.IO) {
provider.forAccount(caldavAccount).makeCollection(name, color, icon)
provider.forAccount(caldavAccount).makeCollection(name, color)
}
val calendar = CaldavCalendar(
uuid = UUIDHelper.newUUID(),
@ -69,7 +67,7 @@ class CaldavCalendarViewModel @Inject constructor(
) =
doRequest {
withContext(Dispatchers.IO) {
provider.forAccount(account, calendar.url!!).updateCollection(name, color, icon)
provider.forAccount(account, calendar.url!!).updateCollection(name, color)
}
val result = calendar.copy(
name = name,
@ -98,10 +96,10 @@ class CaldavCalendarViewModel @Inject constructor(
list: CaldavCalendar,
input: String
) = doRequest {
val href = when (account.serverType) {
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> "principal:principals/users/$input"
else -> "mailto:$input"
}
val href = if (account.serverType == CaldavAccount.SERVER_OWNCLOUD)
"principal:principals/users/$input"
else
"mailto:$input"
withContext(Dispatchers.IO) {
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.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CalendarColor
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.*
import at.bitfire.dav4jvm.property.ResourceType.Companion.CALENDAR
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.SyncToken
import org.tasks.data.UUIDHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.caldav.property.CalendarIcon
import org.tasks.caldav.property.Invite
import org.tasks.caldav.property.OCInvite
import org.tasks.caldav.property.OCOwnerPrincipal
import org.tasks.caldav.property.PropertyUtils.NS_OWNCLOUD
import org.tasks.caldav.property.ShareAccess
import org.tasks.data.UUIDHelper
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
@ -109,12 +101,9 @@ open class CaldavClient(
.findHomeset()
}
suspend fun calendars(interceptor: (okhttp3.Response) -> okhttp3.Response = { it }): List<Response> =
suspend fun calendars(interceptor: (Interceptor.Chain) -> okhttp3.Response): List<Response> =
DavResource(
httpClient
.newBuilder()
.addNetworkInterceptor { interceptor(it.proceed(it.request())) }
.build(),
httpClient.newBuilder().addNetworkInterceptor(interceptor).build(),
httpUrl!!
)
.propfind(1, *calendarProperties)
@ -131,44 +120,33 @@ open class CaldavClient(
}
@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 mkcolString = getMkcolString(displayName, color)
davResource.mkCol(mkcolString) {}
if (icon?.isNotBlank() == true) {
davResource.proppatch(CalendarIcon.NAME, icon)
}
davResource.location.toString()
}
@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) {
with(DavResource(httpClient, httpUrl!!)) {
proppatch(DisplayName.NAME, displayName)
if (color != 0) {
proppatch(
CalendarColor.NAME,
String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24)
)
}
if (icon?.isNotBlank() == true) {
proppatch(CalendarIcon.NAME, icon)
}
proppatch(
setProperties = mutableMapOf(DisplayName.NAME to displayName).apply {
if (color != 0) {
put(
CalendarColor.NAME,
String.format("#%06X%02X", color and 0xFFFFFF, color ushr 24)
)
}
},
removeProperties = if (color == 0) listOf(CalendarColor.NAME) else emptyList(),
callback = { _, _ -> },
)
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)
private fun getMkcolString(displayName: String, color: Int): String {
val xmlPullParserFactory = XmlPullParserFactory.newInstance()
@ -225,8 +203,8 @@ open class CaldavClient(
href: String,
) {
when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV -> shareSabredav(href)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> shareOwncloud(href)
SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> shareSabredav(href)
SERVER_OWNCLOUD -> shareOwncloud(href)
else -> throw IllegalArgumentException()
}
}
@ -265,8 +243,8 @@ open class CaldavClient(
href: String,
) {
when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV -> removeSabrePrincipal(calendar, href)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> removeOwncloudPrincipal(calendar, href)
SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> removeSabrePrincipal(calendar, href)
SERVER_OWNCLOUD -> removeOwncloudPrincipal(calendar, href)
else -> throw IllegalArgumentException()
}
}
@ -306,19 +284,18 @@ open class CaldavClient(
private val MEDIATYPE_SHARING = "application/davsharing+xml".toMediaType()
private val calendarProperties = arrayOf(
ResourceType.NAME,
DisplayName.NAME,
SupportedCalendarComponentSet.NAME,
GetCTag.NAME,
CalendarColor.NAME,
SyncToken.NAME,
ShareAccess.NAME,
Invite.NAME,
OCOwnerPrincipal.NAME,
OCInvite.NAME,
CurrentUserPrivilegeSet.NAME,
CurrentUserPrincipal.NAME,
CalendarIcon.NAME,
ResourceType.NAME,
DisplayName.NAME,
SupportedCalendarComponentSet.NAME,
GetCTag.NAME,
CalendarColor.NAME,
SyncToken.NAME,
ShareAccess.NAME,
Invite.NAME,
OCOwnerPrincipal.NAME,
OCInvite.NAME,
CurrentUserPrivilegeSet.NAME,
CurrentUserPrincipal.NAME,
)
private suspend fun DavResource.propfind(
@ -334,22 +311,5 @@ open class CaldavClient(
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.RequestBody.Companion.toRequestBody
import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.iCalendar.Companion.fromVtodo
import org.tasks.caldav.property.CalendarIcon
import org.tasks.caldav.property.Invite
import org.tasks.caldav.property.OCAccess
import org.tasks.caldav.property.OCInvite
import org.tasks.caldav.property.OCOwnerPrincipal
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.Companion.NOT_SHARED
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.entity.CaldavAccount
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_OWNCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV
@ -85,7 +84,7 @@ class CaldavSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val inventory: Inventory,
private val firebase: Firebase,
@ -137,7 +136,8 @@ class CaldavSynchronizer @Inject constructor(
private suspend fun synchronize(account: CaldavAccount) {
val caldavClient = provider.forAccount(account)
var serverType = account.serverType
val resources = caldavClient.calendars { response ->
val resources = caldavClient.calendars { chain ->
val response = chain.proceed(chain.request())
if (serverType == SERVER_UNKNOWN) {
serverType = getServerType(account, response.headers)
}
@ -155,10 +155,8 @@ class CaldavSynchronizer @Inject constructor(
val url = resource.href.toString()
var calendar = caldavDao.getCalendarByUrl(account.uuid!!, url)
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 icon = resource[CalendarIcon::class.java]?.icon?.takeIf { it.isNotBlank() }
if (access == ACCESS_UNKNOWN) {
firebase.logEvent(
R.string.event_sync_unknown_access,
@ -166,6 +164,7 @@ class CaldavSynchronizer @Inject constructor(
(resource[ShareAccess::class.java]?.access?.toString() ?: "???")
)
}
val color = calendarColor?.color ?: 0
if (calendar == null) {
calendar = CaldavCalendar(
name = remoteName,
@ -174,22 +173,17 @@ class CaldavSynchronizer @Inject constructor(
uuid = UUIDHelper.newUUID(),
color = color,
access = access,
icon = icon,
)
caldavDao.insert(calendar)
} else if (calendar.name != remoteName
|| calendar.color != color
|| calendar.access != access
|| (icon != null && calendar.icon != icon)
|| calendar.color != color
|| calendar.access != access
) {
calendar = calendar.copy(
color = color,
name = remoteName,
access = access,
icon = icon ?: calendar.icon,
)
calendar.color = color
calendar.name = remoteName
calendar.access = access
caldavDao.update(calendar)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
resource
.principals(account, calendar)
@ -204,11 +198,7 @@ class CaldavSynchronizer @Inject constructor(
private fun getServerType(account: CaldavAccount, headers: Headers) = when {
account.isTasksOrg -> SERVER_TASKS
headers["DAV"]?.contains("oc-resource-sharing") == true ->
if (headers["DAV"]?.let { it.contains("nextcloud-") || it.contains("nc-") } == true)
SERVER_NEXTCLOUD
else
SERVER_OWNCLOUD
headers["DAV"]?.contains("oc-resource-sharing") == true -> SERVER_OWNCLOUD
headers["x-sabre-version"]?.isNotBlank() == true -> SERVER_SABREDAV
headers["server"] == "Openexchange WebDAV" -> SERVER_OPEN_XCHANGE
else -> SERVER_UNKNOWN
@ -225,7 +215,7 @@ class CaldavSynchronizer @Inject constructor(
}
account.error = message
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
if (!isNullOrEmpty(message)) {
Timber.e(message)
}
@ -302,7 +292,7 @@ class CaldavSynchronizer @Inject constructor(
caldavDao.update(caldavCalendar)
Timber.d("Updating parents for ${caldavCalendar.uuid}")
caldavDao.updateParents(caldavCalendar.uuid!!)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun pushLocalChanges(
@ -330,17 +320,9 @@ class CaldavSynchronizer @Inject constructor(
caldavTask: CaldavTask
): Boolean {
try {
val objectId = caldavTask.obj
?: run {
Timber.e("null obj for caldavTask.id=${caldavTask.id} task.id=${caldavTask.task}")
caldavTask.obj = caldavTask.remoteId?.let { "$it.ics" }
caldavTask.obj
}
if (objectId?.isNotBlank() == true) {
if (!isNullOrEmpty(caldavTask.obj)) {
val remote = DavResource(
httpClient = httpClient,
location = httpUrl.newBuilder().addPathSegment(objectId).build(),
)
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build())
remote.delete(null) {}
}
} catch (e: HttpException) {
@ -364,8 +346,8 @@ class CaldavSynchronizer @Inject constructor(
httpClient: OkHttpClient,
httpUrl: HttpUrl
) {
Timber.d("pushing %s", task)
val caldavTask = caldavDao.getTask(task.id) ?: return
Timber.d("pushing caldavTask=$caldavTask task=$task")
if (task.isDeleted) {
if (deleteRemoteResource(httpClient, httpUrl, calendar, caldavTask)) {
taskDeleter.delete(task)
@ -374,19 +356,9 @@ class CaldavSynchronizer @Inject constructor(
}
val data = iCal.toVtodo(account, calendar, caldavTask, task)
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 {
val remote = DavResource(
httpClient = httpClient,
location = httpUrl.newBuilder().addPathSegment(objPath).build(),
)
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build())
remote.put(requestBody) {
if (it.isSuccessful) {
fromResponse(it)?.eTag?.takeIf(String::isNotBlank)?.let { etag ->
@ -464,13 +436,10 @@ class CaldavSynchronizer @Inject constructor(
fun registerFactories() {
PropertyRegistry.register(
listOf(
ShareAccess.Factory(),
Invite.Factory(),
OCOwnerPrincipal.Factory(),
OCInvite.Factory(),
CalendarIcon.Factory,
)
ShareAccess.Factory(),
Invite.Factory(),
OCOwnerPrincipal.Factory(),
OCInvite.Factory(),
)
}
@ -519,4 +488,4 @@ class CaldavSynchronizer @Inject constructor(
else -> INVITE_UNKNOWN
}
}
}
}

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

@ -29,7 +29,6 @@ class LocalListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) =
updateCalendar()
// TODO: prevent deleting the last list
override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) =
onDeleted(true)
}

@ -1,15 +1,18 @@
package org.tasks.caldav
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavTask
import timber.log.Timber
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 fileStorage: FileStorage,
) {
@ -27,7 +30,7 @@ class VtodoCache(
?: return@withContext
source.copyTo(target, overwrite = true)
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? {
@ -66,28 +69,28 @@ class VtodoCache(
suspend fun delete(calendar: CaldavCalendar, caldavTask: CaldavTask) = withContext(Dispatchers.IO) {
fileStorage.getFile(calendar.account, caldavTask.calendar, caldavTask.obj)?.let {
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) {
fileStorage.getFile(calendar.account, calendar.uuid)?.let {
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) {
fileStorage.getFile(account.uuid)?.let {
val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" }
Timber.d("Deleting $it [success=$deleted]")
}
}
suspend fun clear() = withContext(Dispatchers.IO) {
fileStorage.getFile()?.let {
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.CaldavAccount
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.Place
import org.tasks.data.entity.TagData
@ -207,7 +208,7 @@ class iCalendar @Inject constructor(
val task = existing?.task
?.let { taskDao.fetch(it) }
?: taskCreator.createWithValues("").apply {
readOnly = calendar.readOnly()
readOnly = calendar.access == ACCESS_READ_ONLY
taskDao.createNew(this)
}
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
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.PropertyRegistry
object PropertyUtils {
const val NS_TASKS = "http://org.tasks/ns/"
const val NS_OWNCLOUD = "http://owncloud.org/ns"
}
fun PropertyRegistry.register(vararg factories: PropertyFactory) = register(factories.toList())
}

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

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

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

@ -20,8 +20,6 @@ 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
@ -123,194 +121,173 @@ fun HomeScreen(
) {
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()
}
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)
}
is DrawerItem.Header -> {
viewModel.toggleCollapsed(it.header)
}
},
onAddClick = {
scope.launch {
drawerState.close()
when (it.header.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
showNewFilterDialog()
}
},
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_PLACE ->
newList.launch(Intent(context, LocationPickerActivity::class.java))
REQUEST_NEW_TAGS ->
newList.launch(Intent(context, TagSettingsActivity::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)
)
}
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 -> {}
}
else -> Timber.e("Unhandled request code: $it")
}
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 ->
}
},
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,
HelpAndFeedback::class.java
PurchaseActivity::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),
)
}
DrawerAction.HELP_AND_FEEDBACK ->
context.startActivity(
Intent(
context,
HelpAndFeedback::class.java
)
)
}
}
},
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
},
)
}
}
) {
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(
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()
.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(),
.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),
)
}
}
},
)
}
}
}

@ -1,22 +0,0 @@
package org.tasks.compose.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
fun SystemBarScrim(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.background,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color.copy(alpha = 0.8f))
.then(modifier)
)
}

@ -84,6 +84,7 @@ fun DatePickerBottomSheet(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface,
) {
Row(

@ -1,130 +0,0 @@
package org.tasks.compose.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.tasks.themes.ThemeColor
@Composable
fun ColorPicker(
hasPro: Boolean,
colors: List<ThemeColor>,
onSelected: (ThemeColor) -> Unit,
onColorWheelSelected: () -> Unit = {},
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 48.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
item {
ColorWheelCircle(
onClick = onColorWheelSelected,
hasPro = hasPro,
)
}
items(colors) { color ->
ColorCircle(
color = color,
locked = !(hasPro || color.isFree),
onClick = { onSelected(color) }
)
}
}
}
@Composable
private fun ColorCircle(
color: ThemeColor,
locked: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onClick)
.size(48.dp)
.clip(CircleShape)
.background(Color(color.primaryColor))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = CircleShape
),
contentAlignment = Alignment.Center,
) {
if (locked) {
LockIcon(tint = Color(color.colorOnPrimary))
}
}
}
@Composable
private fun ColorWheelCircle(
onClick: () -> Unit,
hasPro: Boolean,
) {
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onClick)
.size(48.dp)
.clip(CircleShape)
.background(
brush = Brush.sweepGradient(
colors = listOf(
Color.Red,
Color.Magenta,
Color.Blue,
Color.Cyan,
Color.Green,
Color.Yellow,
Color.Red
)
)
)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
if (!hasPro) {
LockIcon(tint = Color.Black)
}
}
}
@Composable
private fun LockIcon(tint: Color) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
tint = tint,
modifier = Modifier.size(24.dp)
)
}

@ -1,52 +0,0 @@
package org.tasks.compose.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import org.tasks.themes.ThemeColor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorPickerDialog(
hasPro: Boolean,
colors: List<ThemeColor>,
onDismiss: () -> Unit,
onColorSelected: (ThemeColor) -> Unit,
onColorWheelSelected: () -> Unit = {},
) {
BasicAlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp)
) {
Box(modifier = Modifier.padding(16.dp)) {
ColorPicker(
colors = colors,
onSelected = { color ->
onColorSelected(color)
onDismiss()
},
onColorWheelSelected = {
onColorWheelSelected()
onDismiss()
},
hasPro = hasPro,
)
}
}
}
}

@ -5,25 +5,22 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.tasks.compose.Constants
import org.tasks.themes.ThemeColor
@Composable
fun ColumnScope.ListSettingsContent(
hasPro: Boolean,
color: Int,
colors: List<ThemeColor>,
icon: String,
text: String,
error: String,
requestKeyboard: Boolean,
isNew: Boolean,
setText: (String) -> Unit,
setColor: (Int) -> Unit,
pickColor: () -> Unit,
clearColor: () -> Unit,
pickIcon: () -> Unit,
addShortcutToHome: () -> Unit,
addWidgetToHome: () -> Unit,
extensionContent: @Composable ColumnScope.() -> Unit,
purchase: () -> Unit,
) {
TitleInput(
text = text,
@ -33,11 +30,9 @@ fun ColumnScope.ListSettingsContent(
setText = { setText(it) },
)
SelectColorRow(
hasPro = hasPro,
color = color,
colors = colors,
selectColor = { setColor(it) },
purchase = { purchase() },
selectColor = { pickColor() },
clearColor = { clearColor() },
)
SelectIconRow(
icon = icon,

@ -5,13 +5,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -20,7 +15,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@ -50,7 +44,6 @@ fun ListSettingsScaffold(
content: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
topBar = {
Column {
val context = LocalContext.current
@ -67,9 +60,6 @@ fun ListSettingsScaffold(
)
}
TopAppBar(
windowInsets = TopAppBarDefaults.windowInsets.only(
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = color,
navigationIconContentColor = contentColor,

@ -1,6 +1,5 @@
package org.tasks.compose.settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@ -13,13 +12,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -29,101 +22,26 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.flask.colorpicker.ColorPickerView
import com.flask.colorpicker.builder.ColorPickerDialogBuilder
import org.tasks.R
import org.tasks.compose.Constants
import org.tasks.kmp.org.tasks.compose.settings.SettingRow
import org.tasks.themes.ColorProvider
import org.tasks.themes.TasksTheme
import org.tasks.themes.ThemeColor
@Composable
fun SelectColorRow(
hasPro: Boolean,
color: Int,
colors: List<ThemeColor>,
purchase: () -> Unit,
selectColor: (Int) -> Unit,
) {
var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showColorWheel by rememberSaveable { mutableStateOf(false) }
if (showColorPicker) {
BackHandler {
showColorPicker = false
}
ColorPickerDialog(
hasPro = hasPro,
colors = colors,
onDismiss = { showColorPicker = false },
onColorSelected = {
if (hasPro || it.isFree) {
selectColor(it.primaryColor)
} else {
purchase()
}
},
onColorWheelSelected = {
if (hasPro) {
showColorWheel = true
} else {
purchase()
}
showColorPicker = false
},
)
}
if (showColorWheel) {
BackHandler {
showColorWheel = false
showColorPicker = true
}
val context = LocalContext.current
var selected by remember { mutableIntStateOf(0) }
LaunchedEffect(showColorWheel) {
if (!showColorWheel) return@LaunchedEffect
ColorPickerDialogBuilder
.with(context)
.wheelType(ColorPickerView.WHEEL_TYPE.CIRCLE)
.density(7)
.setOnColorChangedListener { which ->
selected = which
}
.setOnColorSelectedListener { which ->
selected = which
}
.lightnessSliderOnly()
.setPositiveButton(R.string.ok) { _, _, _ ->
selectColor(selected)
}
.setNegativeButton(R.string.cancel) { _, _ ->
showColorPicker = true
}
.apply {
if (color != 0) {
initialColor(color)
}
}
.build()
.apply {
setOnDismissListener {
showColorWheel = false
}
}
.show()
}
}
selectColor: () -> Unit,
clearColor: () -> Unit
) =
SettingRow(
modifier = Modifier.clickable(onClick = { showColorPicker = true }),
modifier = Modifier.clickable(onClick = selectColor),
left = {
val context = LocalContext.current
val adjusted = remember(color) {
ColorProvider(context).getThemeColor(color).primaryColor
}
Box(
modifier = Modifier.size(56.dp),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = { selectColor() }) {
if (color == 0) {
Icon(
imageVector = Icons.Outlined.NotInterested,
@ -131,11 +49,15 @@ fun SelectColorRow(
contentDescription = null
)
} else {
val borderColor =
colorResource(R.color.icon_tint_with_alpha) // colorResource(R.color.text_tertiary)
Canvas(modifier = Modifier.size(24.dp)) {
drawCircle(color = Color(adjusted))
drawCircle(color = borderColor, style = Stroke(width = 4.0f))
val borderColor = colorResource(R.color.icon_tint_with_alpha) // colorResource(R.color.text_tertiary)
Box(
modifier = Modifier.size(56.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.size(24.dp)) {
drawCircle(color = Color(adjusted))
drawCircle(color = borderColor, style = Stroke(width = 4.0f))
}
}
}
}
@ -148,7 +70,7 @@ fun SelectColorRow(
},
right = {
if (color != 0) {
IconButton(onClick = { selectColor(0) }) {
IconButton(onClick = clearColor) {
Icon(
imageVector = Icons.Outlined.Clear,
contentDescription = null
@ -157,18 +79,15 @@ fun SelectColorRow(
}
}
)
}
@Composable
@Preview(showBackground = true)
private fun ColorSelectPreview () {
TasksTheme {
SelectColorRow(
hasPro = true,
colors = emptyList(),
purchase = {},
color = Color.Red.toArgb(),
selectColor = {},
clearColor = {}
)
}
}

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

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

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

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

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

@ -15,12 +15,11 @@ import com.todoroo.astrid.service.TaskCreator
import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase
import org.tasks.data.createDueDate
import org.tasks.data.*
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -39,7 +38,7 @@ import java.net.HttpRetryException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.Collections
import java.util.*
import javax.inject.Inject
import javax.net.ssl.SSLException
import kotlin.math.max
@ -56,7 +55,7 @@ class GoogleTaskSynchronizer @Inject constructor(
private val defaultFilterProvider: DefaultFilterProvider,
private val permissionChecker: PermissionChecker,
private val googleAccountManager: GoogleAccountManager,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val invokers: InvokerFactory,
private val alarmDao: AlarmDao,
@ -94,7 +93,7 @@ class GoogleTaskSynchronizer @Inject constructor(
firebase.reportException(e)
} finally {
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
Timber.d("%s: end sync", account)
}
}
@ -126,50 +125,37 @@ class GoogleTaskSynchronizer @Inject constructor(
preferences.setString(R.string.p_default_list, null)
}
}
val failedTasks = mutableSetOf<Long>()
var retryTaskId = pushLocalChanges(account, gtasksInvoker)
while (retryTaskId != null) {
if (failedTasks.contains(retryTaskId)) {
throw IOException("Invalid Task ID: $retryTaskId")
}
failedTasks.add(retryTaskId)
Timber.d("Retrying push local changes due to stale task ID $retryTaskId (${failedTasks.size} total failed tasks)")
delay(1000)
retryTaskId = pushLocalChanges(account, gtasksInvoker)
}
pushLocalChanges(account, gtasksInvoker)
for (list in caldavDao.getCalendarsByAccount(account.uuid!!)) {
if (isNullOrEmpty(list.uuid)) {
firebase.reportException(RuntimeException("Empty remote id"))
continue
}
fetchAndApplyRemoteChanges(gtasksInvoker, list)
gtasksInvoker.updatePositions(list.uuid!!)
if (!preferences.isPositionHackEnabled) {
googleTaskDao.reposition(caldavDao, list.uuid!!)
}
}
if (preferences.isPositionHackEnabled) {
for (list in gtaskLists) {
val tasks = fetchPositions(gtasksInvoker, list.id)
for (task in tasks) {
googleTaskDao.updatePosition(task.id, task.parent, task.position)
}
googleTaskDao.reposition(caldavDao, list.id)
}
}
// account.etag = eTag
account.error = ""
}
@Throws(IOException::class)
private suspend fun GtasksInvoker.updatePositions(list: String) {
// Unfortunately this is necessary because Google broke the API
// https://issuetracker.google.com/issues/132432317
Timber.d("updatePositions(list=${list})")
fetchPositions(list).forEach { task ->
googleTaskDao.updatePosition(task.id, task.parent, task.position)
}
googleTaskDao.reposition(caldavDao, list)
}
@Throws(IOException::class)
private suspend fun GtasksInvoker.fetchPositions(listId: String): List<Task> {
private suspend fun fetchPositions(
gtasksInvoker: GtasksInvoker, listId: String): List<Task> {
val tasks: MutableList<Task> = ArrayList()
var nextPageToken: String? = null
do {
val taskList = getAllPositions(listId, nextPageToken)
val taskList = gtasksInvoker.getAllPositions(listId, nextPageToken)
taskList?.items?.let {
tasks.addAll(it)
}
@ -179,20 +165,16 @@ class GoogleTaskSynchronizer @Inject constructor(
}
@Throws(IOException::class)
private suspend fun pushLocalChanges(account: CaldavAccount, gtasksInvoker: GtasksInvoker): Long? {
private suspend fun pushLocalChanges(account: CaldavAccount, gtasksInvoker: GtasksInvoker) {
val tasks = taskDao.getGoogleTasksToPush(account.uuid!!)
for (task in tasks) {
val staleTaskId = pushTask(task, account.uuid!!, gtasksInvoker)
if (staleTaskId != null) {
return staleTaskId
}
pushTask(task, gtasksInvoker)
}
return null
}
@Throws(IOException::class)
private suspend fun pushTask(task: org.tasks.data.entity.Task, account: String, gtasksInvoker: GtasksInvoker): Long? {
for (deleted in googleTaskDao.getDeletedByTaskId(task.id, account)) {
private suspend fun pushTask(task: org.tasks.data.entity.Task, gtasksInvoker: GtasksInvoker) {
for (deleted in googleTaskDao.getDeletedByTaskId(task.id)) {
deleted.remoteId?.let {
try {
gtasksInvoker.deleteGtask(deleted.calendar, it)
@ -205,7 +187,7 @@ class GoogleTaskSynchronizer @Inject constructor(
}
googleTaskDao.delete(deleted)
}
val gtasksMetadata = googleTaskDao.getByTaskId(task.id) ?: return null
val gtasksMetadata = googleTaskDao.getByTaskId(task.id) ?: return
val remoteModel = Task()
var newlyCreated = false
val remoteId: String?
@ -226,7 +208,7 @@ class GoogleTaskSynchronizer @Inject constructor(
// creating a task which may end up being cancelled. Also don't sync new but already
// deleted tasks
if (newlyCreated && (isNullOrEmpty(task.title) || task.deletionDate > 0)) {
return null
return
}
// Update the remote model's changed properties
@ -249,11 +231,10 @@ class GoogleTaskSynchronizer @Inject constructor(
val parent = task.parent
val localParent = if (parent > 0) googleTaskDao.getRemoteId(parent) else null
val previous = googleTaskDao.getPrevious(
listId, if (isNullOrEmpty(localParent)) 0 else parent, task.order ?: 0)
listId!!, if (isNullOrEmpty(localParent)) 0 else parent, task.order ?: 0)
val created: Task? = try {
gtasksInvoker.createGtask(listId, remoteModel, localParent, previous)
} catch (e: HttpNotFoundException) {
Timber.e(e, "Failed to create task, retry without parent or order")
gtasksInvoker.createGtask(listId, remoteModel, null, null)
}
if (created != null) {
@ -261,10 +242,8 @@ class GoogleTaskSynchronizer @Inject constructor(
gtasksMetadata.remoteId = created.id
gtasksMetadata.calendar = listId
setOrderAndParent(gtasksMetadata, created, task)
Timber.d("Created new task: $gtasksMetadata")
} else {
Timber.e("Empty response when creating task")
return null
return
}
} else {
try {
@ -273,64 +252,29 @@ class GoogleTaskSynchronizer @Inject constructor(
val parent = task.parent
val localParent = if (parent > 0) googleTaskDao.getRemoteId(parent) else null
val previous = googleTaskDao.getPrevious(
listId,
listId!!,
if (localParent.isNullOrBlank()) 0 else parent,
task.order ?: 0,
)
task.order ?: 0)
gtasksInvoker
.moveGtask(
listId = listId,
taskId = remoteModel.id,
parentId = localParent,
previousId = previous,
)
?.let {
setOrderAndParent(
googleTask = gtasksMetadata,
task = it,
local = task,
)
}
.moveGtask(listId, remoteModel.id, localParent, previous)
?.let { setOrderAndParent(gtasksMetadata, it, task) }
} catch (e: GoogleJsonResponseException) {
if (e.statusCode == 400) {
Timber.w("HTTP 400: clearing parent and order")
firebase.reportException(e)
taskDao.setParent(0L, listOf(task.id))
taskDao.setOrder(task.id, 0L)
googleTaskDao.update(gtasksMetadata.copy(isMoved = false))
return task.id
Timber.e(e)
} else {
throw e
}
}
}
// TODO: don't updateGtask if it was only moved
try {
gtasksInvoker.updateGtask(listId, remoteModel)
} catch (e: GoogleJsonResponseException) {
if (e.statusCode == 400 && e.details?.message == "Invalid task ID") {
Timber.w("HTTP 400: Invalid task ID for ${remoteModel.id}, clearing to recreate on next sync")
firebase.reportException(e)
googleTaskDao.update(
gtasksMetadata.copy(
remoteId = "",
isMoved = false,
)
)
return task.id
} else {
throw e
}
}
} catch (_: HttpNotFoundException) {
Timber.w("HTTP 404, deleting $gtasksMetadata")
gtasksInvoker.updateGtask(listId, remoteModel)
} catch (e: HttpNotFoundException) {
googleTaskDao.delete(gtasksMetadata)
return null
return
}
}
gtasksMetadata.isMoved = false
write(task, gtasksMetadata)
return null
}
@Throws(IOException::class)

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

@ -3,7 +3,6 @@ package org.tasks.injection
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.todoroo.andlib.utility.AndroidUtilities.atLeastAndroid16
import kotlinx.coroutines.runBlocking
import org.tasks.analytics.Firebase
import timber.log.Timber
@ -15,11 +14,7 @@ abstract class BaseWorker(
) : Worker(context, workerParams) {
override fun doWork(): Result {
if (atLeastAndroid16()) {
Timber.d("${javaClass.simpleName} $id $inputData attempt=$runAttemptCount ${if (runAttemptCount > 0) "stopReason=$stopReason" else ""}")
} else {
Timber.d("${javaClass.simpleName} $id $inputData attempt=$runAttemptCount")
}
Timber.d("${javaClass.simpleName} $id $inputData")
return try {
runBlocking {
run()

@ -34,7 +34,7 @@ class MigrateLocalWork @AssistedInject constructor(
caldavDao.getCalendarsByAccount(fromAccount.uuid!!).forEach {
caldavDao.update(
it.copy(
url = caldavClient.makeCollection(it.name!!, it.color, it.icon),
url = caldavClient.makeCollection(it.name!!, it.color),
account = caldavAccount.uuid,
)
)

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

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

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

@ -1,55 +0,0 @@
package org.tasks.jobs
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.tasks.analytics.Firebase
import org.tasks.caldav.CaldavClientProvider
import org.tasks.caldav.property.CalendarIcon
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.injection.BaseWorker
import timber.log.Timber
@HiltWorker
class UpgradeIconSyncWork @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
firebase: Firebase,
private val clientProvider: CaldavClientProvider,
private val caldavDao: CaldavDao,
) : BaseWorker(context, workerParams, firebase) {
override suspend fun run(): Result {
var response = Result.success()
caldavDao
.getAccounts(CaldavAccount.TYPE_TASKS, CaldavAccount.TYPE_CALDAV)
.forEach { account ->
Timber.d("Uploading icons for $account")
val caldavClient = clientProvider.forAccount(account)
caldavClient.calendars().forEach { remote ->
val url = remote.href
val calendar = caldavDao
.getCalendarByUrl(account.uuid!!, url.toString())
?.takeIf { !it.readOnly() && it.icon?.isNotBlank() == true }
?: run {
Timber.d("No icon set for $url")
return@forEach
}
val icon = remote[CalendarIcon::class.java]?.icon
if (icon?.isNotBlank() == true) {
Timber.d("Remote icon already set for $url")
return@forEach
}
Timber.d("Uploading icon to ${calendar.icon} for $url")
caldavClient.updateIcon(
url = url,
icon = calendar.icon,
onFailure = { response = Result.retry() }
)
}
}
return response
}
}

@ -136,7 +136,7 @@ class WorkManagerImpl(
.setConstraints(networkConstraints)
workManager.enqueueUniquePeriodicWork(
TAG_BACKGROUND_SYNC,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.KEEP,
builder.build()
)
} else {
@ -183,7 +183,7 @@ class WorkManagerImpl(
throttle.run {
workManager.enqueueUniquePeriodicWork(
TAG_REMOTE_CONFIG,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequest.Builder(
RemoteConfigWork::class.java, REMOTE_CONFIG_INTERVAL_HOURS, TimeUnit.HOURS)
.setConstraints(networkConstraints)
@ -207,6 +207,9 @@ class WorkManagerImpl(
enqueue(builder)
}
private val networkConstraints: Constraints
get() = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
override fun updatePurchases() =
enqueueUnique(TAG_UPDATE_PURCHASES, UpdatePurchaseWork::class.java)
@ -257,6 +260,3 @@ class WorkManagerImpl(
private fun <B : WorkRequest.Builder<B, *>, W : WorkRequest> WorkRequest.Builder<B, W>.setInputData(
vararg pairs: Pair<String, Any?>
): B = setInputData(workDataOf(*pairs))
val networkConstraints: Constraints
get() = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()

@ -8,7 +8,6 @@ import android.os.Bundle
import android.os.Parcelable
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@ -16,9 +15,6 @@ import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.widget.ContentLoadingProgressBar
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@ -70,7 +66,6 @@ class LocationPickerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListe
private lateinit var loadingIndicator: ContentLoadingProgressBar
private lateinit var chooseRecentLocation: View
private lateinit var recyclerView: RecyclerView
private lateinit var selectThisLocation: View
@Inject lateinit var theme: Theme
@Inject lateinit var locationDao: LocationDao
@ -93,7 +88,6 @@ class LocationPickerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListe
private lateinit var search: MenuItem
private var searchJob: Job? = null
private val viewModel: PlaceSearchViewModel by viewModels()
private var systemBarsBottom = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -101,13 +95,6 @@ class LocationPickerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListe
window.statusBarColor = ContextCompat.getColor(this, android.R.color.transparent)
val binding = ActivityLocationPickerBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
systemBarsBottom = systemBars.bottom
insets
}
toolbar = binding.toolbar
appBarLayout = binding.appBarLayout
toolbarLayout = binding.collapsingToolbarLayout
@ -118,7 +105,6 @@ class LocationPickerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListe
loadingIndicator = binding.loadingIndicator
chooseRecentLocation = binding.chooseRecentLocation
recyclerView = binding.recentLocations
selectThisLocation = binding.selectThisLocation
val configuration = resources.configuration
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.smallestScreenWidthDp < 480) {
@ -332,15 +318,9 @@ class LocationPickerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListe
params.height = height
chooseRecentLocation.visibility = View.GONE
collapseToolbar()
selectThisLocation.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = systemBarsBottom
}
} else {
params.height = height * 75 / 100
chooseRecentLocation.visibility = View.VISIBLE
selectThisLocation.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = 0
}
}
}

@ -4,14 +4,12 @@ import android.annotation.SuppressLint
import android.app.Application
import android.os.Process
import android.util.Log
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.BuildConfig
import org.tasks.backup.TasksJsonExporter
import org.tasks.logging.LogFormatter.Companion.LINE_SEPARATOR
import org.tasks.preferences.Device
import timber.log.Timber
@ -29,8 +27,6 @@ import javax.inject.Singleton
@Singleton
class FileLogger @Inject constructor(
private val context: Application,
private val device: Lazy<Device>,
private val tasksJsonExporter: Lazy<TasksJsonExporter>,
) : Timber.DebugTree() {
private val logDirectory = File(context.cacheDir, "logs").apply { mkdirs() }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -86,10 +82,7 @@ class FileLogger @Inject constructor(
Timber.e(e, "Failed to save logcat")
}
zos.putNextEntry(ZipEntry("device.txt"))
zos.write(device.get().debugInfo.toByteArray())
zos.closeEntry()
zos.putNextEntry(ZipEntry("settings.json"))
tasksJsonExporter.get().doSettingsExport(zos)
zos.write(Device(context).debugInfo.toByteArray())
zos.closeEntry()
fileHandler.flush()
logDirectory

@ -13,7 +13,7 @@ import com.todoroo.astrid.utility.Constants
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.data.dao.LocationDao
import org.tasks.data.dao.NotificationDao
@ -45,7 +45,7 @@ class NotificationManager @Inject constructor(
private val notificationDao: NotificationDao,
private val taskDao: TaskDao,
private val locationDao: LocationDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val notificationManager: ThrottledNotificationManager,
private val markdownProvider: MarkdownProvider,
private val permissionChecker: PermissionChecker,
@ -86,8 +86,7 @@ class NotificationManager @Inject constructor(
@SuppressLint("MissingPermission")
suspend fun restoreNotifications(cancelExisting: Boolean) {
if (!permissionChecker.canNotify()) {
Timber.w("Notifications disabled")
if (!permissionChecker.hasNotificationPermission()) {
return
}
val notifications = notificationDao.getAllOrdered()
@ -128,7 +127,7 @@ class NotificationManager @Inject constructor(
nonstop: Boolean,
fiveTimes: Boolean
) {
if (!permissionChecker.canNotify()) {
if (!permissionChecker.hasNotificationPermission()) {
return
}
val existingNotifications = notificationDao.getAllOrdered()
@ -176,7 +175,7 @@ class NotificationManager @Inject constructor(
useGroupKey = false,
)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
@SuppressLint("MissingPermission")
@ -225,7 +224,7 @@ class NotificationManager @Inject constructor(
nonstop: Boolean,
fiveTimes: Boolean,
) {
if (!permissionChecker.canNotify()) {
if (!permissionChecker.hasNotificationPermission()) {
return
}
if (preUpsideDownCake()) {
@ -456,7 +455,7 @@ class NotificationManager @Inject constructor(
R.string.TPl_notification, r.getQuantityString(R.plurals.Ntasks, count, count)
)
val builder =
NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_TIMERS)
NotificationCompat.Builder(context, NotificationManager.NOTIFICATION_CHANNEL_TIMERS)
.setContentIntent(pendingIntent)
.setContentTitle(appName)
.setContentText(text)

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

@ -3,12 +3,7 @@ package org.tasks.preferences
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
@ -27,21 +22,8 @@ abstract class BasePreferences : ThemedInjectingAppCompatActivity(),
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
val binding = ActivityPreferencesBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val systemBars = insets.getSystemWindowInsets()
toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemBars.top
}
binding.settings.updatePadding(bottom = systemBars.bottom)
insets
}
toolbar = binding.toolbar.toolbar
if (savedInstanceState == null) {
val rootPreference = getRootPreference()

@ -11,7 +11,6 @@ import org.tasks.data.dao.TagDataDao
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_ONLY
import org.tasks.data.entity.CaldavTask
import org.tasks.data.entity.Task
import org.tasks.data.getLocalList
import org.tasks.filters.CaldavFilter
import org.tasks.filters.CustomFilter
import org.tasks.filters.Filter
@ -56,7 +55,7 @@ class DefaultFilterProvider @Inject constructor(
fun setLastViewedFilter(filter: Filter) = setFilterPreference(filter, R.string.p_last_viewed_list)
suspend fun getLastViewedFilter() = getFilterFromPreference(R.string.p_last_viewed_list)
private suspend fun getLastViewedFilter() = getFilterFromPreference(R.string.p_last_viewed_list)
suspend fun getDefaultOpenFilter() = getFilterFromPreference(R.string.p_default_open_filter)
@ -84,19 +83,14 @@ class DefaultFilterProvider @Inject constructor(
private suspend fun getAnyList(): CaldavFilter {
val filter = caldavDao
.getCalendars()
.filterNot { it.readOnly() }
.filterNot { it.access == ACCESS_READ_ONLY }
.getOrNull(0)
?.let { list ->
list.account
?.let { caldavDao.getAccountByUuid(it) }
?.let { account -> CaldavFilter(calendar = list, account = account) }
}
?: caldavDao.getLocalList().let { list ->
CaldavFilter(
calendar = list,
account = caldavDao.getAccountByUuid(list.account!!)!!
)
}
?: throw IllegalStateException()
defaultList = filter
return filter
}

@ -0,0 +1,77 @@
package org.tasks.preferences;
import static java.util.Arrays.asList;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.Build;
import android.speech.RecognizerIntent;
import com.google.common.base.Joiner;
import org.tasks.BuildConfig;
import java.util.List;
import javax.inject.Inject;
import dagger.hilt.android.qualifiers.ApplicationContext;
import timber.log.Timber;
public class Device {
private final Context context;
@Inject
public Device(@ApplicationContext Context context) {
this.context = context;
}
public boolean hasCamera() {
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA);
}
public boolean hasMicrophone() {
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_MICROPHONE);
}
public boolean voiceInputAvailable() {
PackageManager pm = context.getPackageManager();
List<ResolveInfo> activities =
pm.queryIntentActivities(new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), 0);
return (activities.size() != 0);
}
public String getDebugInfo() {
try {
return Joiner.on("\n")
.join(
asList(
"",
"----------",
"Tasks: "
+ BuildConfig.VERSION_NAME
+ " ("
+ BuildConfig.FLAVOR
+ " build "
+ BuildConfig.VERSION_CODE
+ ")",
"Android: " + Build.VERSION.RELEASE + " (" + Build.DISPLAY + ")",
"Locale: " + java.util.Locale.getDefault(),
"Model: " + Build.MANUFACTURER + " " + Build.MODEL,
"Product: " + Build.PRODUCT + " (" + Build.DEVICE + ")",
"Kernel: "
+ System.getProperty("os.version")
+ " ("
+ Build.VERSION.INCREMENTAL
+ ")",
"----------",
""));
} catch (Exception e) {
Timber.e(e);
}
return "";
}
}

@ -1,60 +0,0 @@
package org.tasks.preferences
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import android.speech.RecognizerIntent
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.BuildConfig
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
class Device @Inject constructor(
@ApplicationContext private val context: Context,
private val permissionChecker: PermissionChecker,
) {
@SuppressLint("UnsupportedChromeOsCameraSystemFeature")
fun hasCamera() = context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
fun hasMicrophone() = context.packageManager.hasSystemFeature(PackageManager.FEATURE_MICROPHONE)
fun voiceInputAvailable(): Boolean {
val pm = context.packageManager
val activities =
pm.queryIntentActivities(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), 0)
return activities.isNotEmpty()
}
private fun isDontKeepActivitiesEnabled(): Boolean? {
return try {
Settings.Global.getInt(context.contentResolver, Settings.Global.ALWAYS_FINISH_ACTIVITIES) == 1
} catch (e: Exception) {
Timber.e("failed to fetch ${Settings.Global.ALWAYS_FINISH_ACTIVITIES}: ${e.message}")
null
}
}
val debugInfo: String
get() = """
----------
Tasks: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR} build ${BuildConfig.VERSION_CODE})
Android: ${Build.VERSION.RELEASE} (${Build.DISPLAY})
Locale: ${Locale.getDefault()}
Model: ${Build.MANUFACTURER} ${Build.MODEL}
Product: ${Build.PRODUCT} (${Build.DEVICE})
Kernel: ${System.getProperty("os.version")} (${Build.VERSION.INCREMENTAL})
----------
notifications: ${permissionChecker.hasNotificationPermission()}
reminders: ${permissionChecker.hasAlarmsAndRemindersPermission()}
background location: ${permissionChecker.canAccessBackgroundLocation()}
foreground location: ${permissionChecker.canAccessForegroundLocation()}
calendar: ${permissionChecker.canAccessCalendars()}
----------
dont keep activities: ${isDontKeepActivitiesEnabled()}
----------
""".trimIndent()
}

@ -41,12 +41,9 @@ public class PermissionChecker {
return !atLeastTiramisu() || checkPermissions(permission.POST_NOTIFICATIONS);
}
public boolean hasAlarmsAndRemindersPermission() {
return org.tasks.extensions.Context.INSTANCE.canScheduleExactAlarms(context);
}
public boolean canNotify() {
return hasAlarmsAndRemindersPermission() && hasNotificationPermission();
return org.tasks.extensions.Context.INSTANCE.canScheduleExactAlarms(context)
&& hasNotificationPermission();
}
private boolean checkPermissions(String... permissions) {

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save