Compare commits

..

63 Commits

Author SHA1 Message Date
Arnau Mora
46e8c4522b Updated name, package and color
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-10-25 17:47:23 +02:00
Sunik Kupfer
d00353ba9c Replace android sync framework result class with our own (#1094)
* Use our own SyncResult data class

* Minor comment changes

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-25 14:13:43 +02:00
Ricki Hirner
dc0d4f371a More compatible processing of multiget responses (#1099)
* Ignore multi-get responses without calendar/contact data

* Add comment
2024-10-25 12:44:54 +02:00
Arnau Mora
3d198f5454 LocalAddressBook: rename account to addressbookAccount (#1095)
* Upgraded vcard4android

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Replaced all usages of addressBookAccount

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Minor changes

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-22 14:07:53 +02:00
Ricki Hirner
1802740a2d Version bump to 4.4.3.2 2024-10-20 16:32:12 +02:00
Ricki Hirner
138e517d23 LocalAddressBook: move contacts when renaming the address book account (#1084)
* LocalAddressBook: move contacts when renaming the address book account

* Don't make contacts dirty when moving

* Move isDirty to tests because it's only required for tests

* We don't have to set the user-data twice

* Add test for groups
2024-10-20 16:31:31 +02:00
Ricki Hirner
166b2ac220 Bump version to 4.4.3.1 2024-10-18 16:53:28 +02:00
Ricki Hirner
450a418994 Don't crash when logging null parameter (#1081) 2024-10-18 16:51:26 +02:00
Ricki Hirner
d4e9e2a8f7 Reduce warnings, lint 2024-10-17 16:40:09 +02:00
Ricki Hirner
ecc59dda99 Update dependencies 2024-10-17 16:32:35 +02:00
Ricki Hirner
9c2afbab09 Fetch translations from Transifex 2024-10-17 16:14:04 +02:00
Ricki Hirner
cebf2d9dfd Version bump to 4.4.3 2024-10-17 16:10:18 +02:00
Sunik Kupfer
5f49c675c8 Try to adhere to google play guidelines for background location permission (bitfireAT/davx5#614)
* Always show "WiFi SSID card" in account settings when SSID restriction is active and adapt content according to whether all conditions are met or not

* Move explanation to top and add paragraph

* Remove unnecessary parenthesis

* Use two text composables and no spacer

* Fix preview
2024-10-16 11:11:44 +02:00
Ricki Hirner
62c46e123d Avoid very long log lines and resulting OOM (#1073)
* PlainTextFormatter: truncate log lines to ~10000 characters

* Update vcard4android which doesn't dump Contact photos anymore

* Add truncation test
2024-10-15 15:22:03 +02:00
Ricki Hirner
5f1215801d Update dependencies 2024-10-15 12:32:45 +02:00
Ricki Hirner
930977c44b Make collections in CollectionsList clickable again (#1075)
CollectionsList: use modifier again
2024-10-15 11:40:09 +02:00
Arnau Mora
a0d152a66f Fixed surface container color in dark theme (#1069)
* Fixed surface color in dark theme

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Account screen: higher contrast for collection cards

* Account screen: use normal instead of elevated cards

* Adapt colors of card lists

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-14 15:03:08 +02:00
Ricki Hirner
a8883427bc Fetch translations from Transifex 2024-10-10 18:25:36 +02:00
Ricki Hirner
7a8dbef80b Version bump to 4.4.3-rc.1 2024-10-10 18:23:05 +02:00
Ricki Hirner
4a40bb3d6f Syncer: make sure collections which are deleted are not synced (#1065)
* Syncer: make sure collections which are deleted are not synced

* Syncer: log when local collection is removed

* Update KDoc and tests

* Handle all CRUD work in updateCollections

* Update naming, KDoc, tests

* Minor changes (KDoc, naming)
2024-10-10 18:08:13 +02:00
Arnau Mora
c805e549ff [Push] Show notification on push notification (until sync is started) (#1043)
* Added sync pending notification

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Moved notify function to PushNotificationManager

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added ongoing and only-alert-once

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added notification hiding

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Got rid of `cancel`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed comments

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added content intent and sub text

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Updated usages

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Review changes

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-10-10 15:50:53 +02:00
Sunik Kupfer
26a670c181 Fix old address book accounts not being deleted (#1039)
* Log warning instead of throwing exception when not possible to find account for address book account

* Run sync and accounts cleanup in migration

* Rename accounts in migration

* Run account settings migrations on background thread

* Revert "Run account settings migrations on background thread"

This reverts commit 6b578da4f1.

* Add tests for AccountsCleanupWorker

* Move companion object to end of class

* Don't use AccountRepository for address book accounts

* Update account user data in LocalAddressBook

* Minor changes (naming etc)

* Add log line when migrating

* Try to fix test error

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-09 13:28:54 +02:00
Arnau Mora
5b54c9dff0 MKCALENDAR: send VTIMEZONE in calendar-timezone (#1044)
* Upgrade dav4jvm

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* separate CalendarTimezone and CalendarTimezoneId

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed timezone name setting

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed `VTIMEZONE` conversion

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Using text instead of CDATA

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed spec

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added comment

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Renamed `timezoneDef` to `timezoneId`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Upgrade dav4jvm

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* separate CalendarTimezone and CalendarTimezoneId

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed timezone name setting

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed `VTIMEZONE` conversion

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Using text instead of CDATA

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed spec

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added comment

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Renamed `timezoneDef` to `timezoneId`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* [CI] Update workflows to Java 21

* Set default value of the timezone state to null

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-09 00:12:34 +02:00
Ricki Hirner
1ca73b67a4 Add KDoc, rename AccountUtils to SystemAccountUtils (#1059) 2024-10-07 18:52:18 +02:00
Ricki Hirner
194c587476 Update Compose, Kotlin, gradle 2024-10-07 12:38:18 +02:00
Sunik Kupfer
1193027e5f Add account name to address book account name (#1050)
* Add account name to address book account name

* Add parenthesis for account name and a hashtag for the collection id

* Use the correct id

* Move DI entry point to where it is used
2024-10-07 12:26:53 +02:00
Ricki Hirner
7de7980860 Use SafeAndroidUriHandler to prevent crashes when no browser is installed (#1058)
* Use SafeAndroidUriHandler in AppTheme

* UiUtils: use DI for Logger

* SyncWorkerManager: use DI for Logger
2024-10-07 10:44:36 +02:00
Ricki Hirner
fc7f42c6fa Sync worker management: move logic out of companion object (#1056)
* Sync worker management: move logic from companion object to new class

* Fix tests

* Move re-sync inputs from [OneTimeSyncWorker] to [BaseSyncWorker] as they're processed there

* Remove useless Companion
2024-10-07 09:34:02 +02:00
Sunik Kupfer
196bfb3aea Don't use AccountSettings on main thread (#1049)
* Document that AccountSettings shouldn't be used in the main thread

* Throw exception when AccountSettings are used on the main thread

* Don't access AccountSettings on main thread

* Don't access AccountSettings on main thread

* Don't access AccountSettings on main thread
2024-10-03 15:36:42 +02:00
Sunik Kupfer
cb5798833d Ignore lint AppLinkUrlError (#1053) 2024-10-03 15:33:39 +02:00
Ricki Hirner
a1148613e9 [CI] Update workflows to Java 21 2024-10-02 13:08:11 +02:00
Ricki Hirner
12529fa9bd Update toolchain and AGP 2024-10-02 12:54:50 +02:00
Ricki Hirner
d743d19a3d AccountsScreen: better preview 2024-09-19 15:42:45 +02:00
Ricki Hirner
4dcee27e22 Version bump to 4.4.3-beta.1 2024-09-19 15:17:20 +02:00
Sunik Kupfer
b6ceaa7efc Remove concept of main accounts (#989)
* Acquire account settings via address book account

* Extract the code to find an address books main account to the account repository

* Use collection id as reference in address book account

* Remove obsolete baos

* Find main account directly from collection in SyncManager

* Require main account to get account settings

* Stop deleting address book accounts without a main account, since they may exist on their own now

* Require content provider and introduce static deleteByCollection method

* Update KDoc

* Show all address book accounts separately

* Drop mainAccount method

* [DI] Use AssistedInject for LocalAddressBook

* Renaming, remove "main account" concept

* Fix debug info

* AccountsCleanupWorker: Rename main account to account

* Further remove main accounts

* Reduce redundancy

* AccountSettings: check account type

* AccountSettingsMigrations: drop v5 -> v6 migration (not tested anyway)

* AccountRepository: directly delete accounts

* Remove obsolete workerAccount

* Get all address books, even if not sync enabled

* Delete orphan address book accounts

* Rename two more occurrences of main account concept

* AccountSettings: allow test accounts

* Syncer: rename methods for clarity, add KDoc

* Drop empty test class

* Make code more readable and add comment

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-09-19 15:06:45 +02:00
Ricki Hirner
5c6f712d32 Update AGP and dependencies 2024-09-19 14:02:34 +02:00
Arnau Mora
5180b99af2 Moved pull-to-refresh indicator below tabs (#1028)
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-16 15:48:48 +02:00
Arnau Mora
dcb7e315b9 ClickableText for URLs has been deprecated (#1024)
* Got rid of `UrlAnnotation`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Deprecated and suggested a ReplaceWith

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Optimized imports

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Replaced usages of `ClickableTextWithLink`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed `ClickableTextWithLink`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Migrated `ClickableTextWithLink`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Remove experimental text api annotations

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2024-09-16 15:02:17 +02:00
Arnau Mora
111481cd00 Added isLoading to Assistant (#1027)
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-16 14:35:36 +02:00
Ricki Hirner
4dc7df7c53 Reword login text (#1026)
- Change empty accounts text
- Add privacy note to Login screen
- Changed last button text
2024-09-16 14:34:08 +02:00
Arnau Mora
cf609288e1 Update Dependencies (#1017)
* Upgrade dependencies

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Upgrade dependencies

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Migrated pull to refresh

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Migrated `LocalMinimumInteractiveComponentEnforcement`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed disabling of linting

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Optimize imports

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Increased indicator show time

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-14 13:22:43 +02:00
Ricki Hirner
0b9d4cd3b3 Unsubscribe push from unsynced collections (#1011)
* Unsubscribe push from unsynced collections

* Remove subscription from DB, too

* Subscription: catch HTTP errors
2024-09-10 12:07:04 +02:00
Arnau Mora
0581417bba Increase SDK level to 35 (#1003)
* Increase SDK level

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed nullability issues

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Increase SDK level

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Fixed nullability issues

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Replaced `removeFirst()` by `removeAt(0)`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Switched to null check instead of NPE catch

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Using orEmpty

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-10 11:18:40 +02:00
Ricki Hirner
f8fb016a27 [CI] Better test names 2024-09-06 11:29:15 +02:00
Ricki Hirner
8c3d1cdeae InitCalendarProviderRule: make multiple attempts to create a calendar (#1007) 2024-09-06 11:18:08 +02:00
Arnau Mora
4a4dc24cdf dark theme / black text basically unreadable on dark background (#986)
* Provided content color

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Adjusted bar color

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Got rid of theme changes

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Added wrapping scaffold

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Changed colors

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-06 10:07:30 +02:00
Arnau Mora
49a51ef384 Reproducible Builds (for IzzyOnDroid) (#995)
* Added `BUILD_DATE` environment variable

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Excluded `generated`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed argument

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Using build time from git

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Removed unused import

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Got rid of build date

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Got rid of build date

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2024-09-06 09:48:29 +02:00
Arnau Mora
fc698040aa lint fails in AboutActivity (#1001)
* Disabled `CoroutineCreationDuringComposition` in lint

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Moved disable

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-04 17:46:44 +02:00
Ricki Hirner
6cbd71ab50 Update dependencies (including AGP) 2024-09-04 11:03:29 +02:00
Ricki Hirner
47f078dcd7 Version bump to 4.4.3-alpha.1 2024-08-21 12:23:54 +02:00
Ricki Hirner
be6ab8728c Update dependencies and Gradle 2024-08-21 12:18:51 +02:00
Ricki Hirner
2908bba298 [CI] Compile job: only compile sources, not tests 2024-08-21 12:01:22 +02:00
Sunik Kupfer
b962b68631 Use standard content provider instead of TaskProvider in TaskSyncer (#982)
* Update ical4android

* Use standard content provider in TaskSyncer

* Check version instead of acquiring TaskProvider

* Add sync result error

---------

Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2024-08-20 10:47:50 +02:00
Sunik Kupfer
fca7c09105 Tests for sync algorithm (#974)
* Prepare Syncer for tests

* Test sync honors preparation result

* Refactor sync algorithm into smaller testable methods

* Write weak tests for the individual methods

* Update KDoc

* Minor changes
 - update comments
 - update test method spacing
 - replace empty array method

---------

Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-08-19 13:42:22 +02:00
Sunik Kupfer
60c6aba2d2 Log stop reason when sync worker is cancelled (#984) 2024-08-17 16:06:21 +02:00
Ricki Hirner
70f6f2603e SyncAdapterServices: Use a coroutine scope to cancel waiting on framework request (#977)
* SyncAdapterServices: Use a coroutine scope to cancel waiting on framework request

* Added tests
2024-08-14 14:00:54 +02:00
Ricki Hirner
5d4c9c8d94 Don't overwrite calendar/task list color with default color (#971)
Calendars, task lists: always set color at creation, then overwrite only when available from server
2024-08-14 10:13:08 +02:00
Ricki Hirner
4378bee042 [CI] Skip compile job when not on main branch (#978) 2024-08-13 13:19:17 +02:00
Ricki Hirner
3776b50bbc Update dependencies, Kotlin, AGP 2024-08-12 14:19:00 +02:00
Sunik Kupfer
a9c7e1929f Fix sync not running directly after enabling a collection (#966)
Retrieve local sync collections only once and return them on creation
2024-08-12 11:55:35 +02:00
Ricki Hirner
318b9be77e AccountRepository: don't add onAccountsUpdated listener on main thread 2024-08-08 11:30:56 +02:00
Ricki Hirner
26cb845950 Reduce StrictMode annoyance 2024-08-08 10:40:25 +02:00
Sunik Kupfer
eae6d0c578 Fix coincidence naming of LocalCollection members (#957)
* Fix overlapping method name and use interface everywhere

* Fix overlapping property name

* Update logger usage

---------

Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-08-08 10:18:21 +02:00
116 changed files with 3308 additions and 1680 deletions

View File

@@ -32,7 +32,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}

View File

@@ -23,7 +23,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
- name: Prepare keystore

View File

@@ -10,35 +10,35 @@ concurrency:
jobs:
compile:
name: Compile and cache
name: Compile for build cache
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-write-only: ${{ github.ref == 'refs/heads/main-ose' }}
dependency-graph: generate-and-submit # submit Github Dependency Graph info
dependency-graph-continue-on-failure: false
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:assembleDebug
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
test:
needs: compile
name: Tests without emulator
if: ${{ always() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
@@ -49,17 +49,17 @@ jobs:
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
# generates the build caches because it uses more gradle dependencies
test_on_emulator:
needs: compile
name: Tests with emulator
if: ${{ always() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}

View File

@@ -13,27 +13,25 @@ plugins {
// Android configuration
android {
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404020004
versionName = "4.4.2"
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
versionCode = 404030200
versionName = "4.4.3.2"
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 34 // Android 14
targetSdk = 35 // Android 15
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(21)
}
}
@@ -84,6 +82,9 @@ android {
signingConfig = signingConfigs.findByName("bitfire")
}
getByName("debug") {
applicationIdSuffix = ".debug"
}
}
lint {
@@ -118,6 +119,10 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
aboutLibraries {
excludeFields = arrayOf("generated")
}
configurations {
configureEach {
// exclude modules which are in conflict with system libraries
@@ -211,4 +216,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.okhttp.mockwebserver)
}
}

View File

@@ -26,18 +26,15 @@ import java.util.logging.Logger
/**
* JUnit ClassRule which initializes the AOSP CalendarProvider.
* Needed for some "flaky" tests which would otherwise only succeed on second run.
*
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
* if this comment) can find out on how to initialize the calendar provider so that the
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
* please let us know!
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
* maybe by some wrongly synchronized database initialization. So things like querying the instances
* fails in this case.
*
* If you run tests manually, just make sure to ignore the first run after the calendar
* provider has been accessed the first time.
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
* is used the very first time (especially in CI tests / a fresh emulator).
*
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for how to use this rule.
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
*/
class InitCalendarProviderRule private constructor(): ExternalResource() {
@@ -71,13 +68,18 @@ class InitCalendarProviderRule private constructor(): ExternalResource() {
private fun initCalendarProvider(provider: ContentProviderClient) {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
val uri = AndroidCalendar.create(account, provider, ContentValues())
val calendar = AndroidCalendar.findByID(
account,
provider,
LocalCalendar.Factory,
ContentUris.parseId(uri)
)
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
var calendarOrNull: LocalCalendar? = null
for (i in 0..50) {
calendarOrNull = createAndVerifyCalendar(account, provider)
if (calendarOrNull != null)
break
else
Thread.sleep(100)
}
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
try {
// single event init
val normalEvent = Event().apply {
@@ -102,4 +104,20 @@ class InitCalendarProviderRule private constructor(): ExternalResource() {
}
}
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
val uri = AndroidCalendar.create(account, provider, ContentValues())
return try {
AndroidCalendar.findByID(
account,
provider,
LocalCalendar.Factory,
ContentUris.parseId(uri)
)
} catch (e: Exception) {
logger.warning("Couldn't find calendar after creation: $e")
null
}
}
}

View File

@@ -4,23 +4,33 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.os.Bundle
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.LabeledProperty
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import ezvcard.property.Telephone
import java.util.LinkedList
import javax.inject.Inject
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookTest {
@@ -28,54 +38,114 @@ class LocalAddressBookTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
lateinit var mainAccount: Account
lateinit var addressBook: LocalTestAddressBook
private val addressBookAccountType by lazy { context.getString(R.string.account_type_address_book) }
private val addressBookAccount by lazy { Account("sub", addressBookAccountType) }
private val accountManager by lazy { AccountManager.get(context) }
@Before
fun setUp() {
hiltRule.inject()
mainAccount = TestAccountAuthenticator.create()
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
LocalTestAddressBook.createAccount(context)
}
@After
fun tearDown() {
accountManager.removeAccountExplicitly(addressBookAccount)
TestAccountAuthenticator.remove(mainAccount)
// remove address book
addressBook.deleteCollection()
}
/**
* Tests whether contacts are moved (and not lost) when an address book is renamed.
*/
@Test
fun testMainAccount_AddressBookAccount_WithMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle(2).apply {
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
}))
fun test_renameAccount_retainsContacts() {
// insert contact with data row
val uid = "12345"
val contact = Contact(
uid = uid,
displayName = "Test Contact",
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
)
val uri = LocalContact(addressBook, contact, null, null, 0).add()
val id = ContentUris.parseId(uri)
val localContact = addressBook.findContactById(id)
localContact.resetDirty()
assertFalse("Contact is dirty before moving", addressBook.isContactDirty(id))
// check mainAccount()
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
// check whether contact is still here (including data rows) and not dirty
val result = addressBook.findContactById(id)
assertFalse("Contact is dirty after moving", addressBook.isContactDirty(id))
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
}
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle.EMPTY))
/**
* Tests whether groups are moved (and not lost) when an address book is renamed.
*/
@Test
fun test_renameAccount_retainsGroups() {
// insert group
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
val uri = localGroup.add()
val id = ContentUris.parseId(uri)
// check mainAccount(); should fail because there's no main account
assertNull(LocalAddressBook.mainAccount(context, addressBookAccount))
// make sure it's not dirty
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
val group = result.getContact()
assertEquals("Test Group", group.displayName)
}
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_OtherAccount() {
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -11,7 +11,6 @@ import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
@@ -67,7 +66,7 @@ class LocalCalendarTest {
@After
fun tearDown() {
calendar.delete()
calendar.deleteCollection()
}

View File

@@ -74,7 +74,7 @@ class LocalEventTest {
@After
fun removeCalendar() {
calendar.delete()
calendar.deleteCollection()
}

View File

@@ -13,7 +13,6 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
@@ -68,7 +67,7 @@ class LocalGroupTest {
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
lateinit var addressbookFactory: LocalTestAddressBook.Factory
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
@@ -78,8 +77,8 @@ class LocalGroupTest {
fun setup() {
hiltRule.inject()
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
// clear contacts
addressBookGroupsAsCategories.clear()

View File

@@ -5,24 +5,38 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import org.junit.Assert.assertTrue
import java.io.FileNotFoundException
import java.util.logging.Logger
class LocalTestAddressBook(
context: Context,
provider: ContentProviderClient,
override val groupMethod: GroupMethod
): LocalAddressBook(context, ACCOUNT, provider) {
class LocalTestAddressBook @AssistedInject constructor(
@Assisted provider: ContentProviderClient,
@Assisted override val groupMethod: GroupMethod,
accountSettingsFactory: AccountSettings.Factory,
collectionRepository: DavCollectionRepository,
@ApplicationContext context: Context,
logger: Logger,
serviceRepository: DavServiceRepository
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
@AssistedFactory
interface Factory {
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
}
override var mainAccount: Account?
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
override var readOnly: Boolean
get() = false
set(_) = throw NotImplementedError()
@@ -35,4 +49,49 @@ class LocalTestAddressBook(
group.delete()
}
/**
* Returns the dirty flag of the given contact.
*
* @return true if the contact is dirty, false otherwise
*
* @throws FileNotFoundException if the contact can't be found
*/
fun isContactDirty(id: Long): Boolean {
val uri = ContentUris.withAppendedId(rawContactsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
/**
* Returns the dirty flag of the given contact group.
*
* @return true if the group is dirty, false otherwise
*
* @throws FileNotFoundException if the group can't be found
*/
fun isGroupDirty(id: Long): Boolean {
val uri = ContentUris.withAppendedId(groupsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
fun createAccount(context: Context) {
val am = AccountManager.get(context)
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
}
}
}

View File

@@ -55,6 +55,9 @@ class CachedGroupMembershipHandlerTest {
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@@ -70,7 +73,7 @@ class CachedGroupMembershipHandlerTest {
@Test
fun testMembership() {
val addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)

View File

@@ -56,6 +56,9 @@ class GroupMembershipBuilderTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@@ -71,7 +74,7 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
assertEquals(1, result.size)
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
@@ -84,7 +87,7 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)

View File

@@ -57,6 +57,9 @@ class GroupMembershipHandlerTest {
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject @ApplicationContext
lateinit var context: Context
@@ -71,7 +74,7 @@ class GroupMembershipHandlerTest {
@Test
fun testMembership_GroupsAsCategories() {
val addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
val contact = Contact()
@@ -87,7 +90,7 @@ class GroupMembershipHandlerTest {
@Test
fun testMembership_GroupsAsVCards() {
val addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)

View File

@@ -7,11 +7,11 @@ package at.bitfire.davdroid.sync
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalCollection
class LocalTestCollection: LocalCollection<LocalTestResource> {
class LocalTestCollection(
override val collectionUrl: String = "http://example.com/test/"
): LocalCollection<LocalTestResource> {
override val tag = "LocalTestCollection"
override val url: String
get() = "https://example.com"
override val title = "Local Test Collection"
override var lastSyncState: SyncState? = null
@@ -21,7 +21,7 @@ class LocalTestCollection: LocalCollection<LocalTestResource> {
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun delete(): Boolean = true
override fun deleteCollection(): Boolean = true
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }

View File

@@ -0,0 +1,192 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.concurrent.Executors
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@HiltAndroidTest
class SyncAdapterServicesTest {
lateinit var account: Account
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var syncConditionsFactory: SyncConditions.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
@get:Rule
val hiltRule = HiltAndroidRule(this)
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
@get:Rule
val timeoutRule: Timeout = Timeout.seconds(5)
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
unmockkAll()
}
private fun syncAdapter(
syncWorkerManager: SyncWorkerManager
): SyncAdapterService.SyncAdapter =
SyncAdapterService.SyncAdapter(
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
serviceRepository = serviceRepository,
context = context,
logger = logger,
syncConditionsFactory = syncConditionsFactory,
syncWorkerManager = syncWorkerManager
)
@Test
fun testSyncAdapter_onPerformSync_cancellation() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
runBlocking {
val sync = launch {
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
// simulate incoming cancellation from sync framework
syncAdapter.onSyncCanceled()
// wait for sync to finish (should happen immediately)
sync.join()
}
}
}
@Test
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function
// immediate timeout (instead of really waiting)
coEvery { withTimeout(any<Long>(), any<suspend CoroutineScope.() -> Unit>()) } throws CancellationException("Simulated timeout")
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
}
}
@Test
fun testSyncAdapter_onPerformSync_runsInTime() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker immediately returns with success
val success = mockk<WorkInfo>()
every { success.state } returns WorkInfo.State.SUCCEEDED
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow {
emit(listOf(success))
delay(60000) // keep the flow active
}
// should just run
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
}
}

View File

@@ -6,7 +6,6 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
@@ -522,7 +521,7 @@ class SyncManagerTest {
}
) = syncManagerFactory.create(
account,
accountSettingsFactory.forAccount(account),
accountSettingsFactory.create(account),
arrayOf(),
"TestAuthority",
HttpClient.Builder(context).build(),

View File

@@ -0,0 +1,206 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var testSyncer: TestSyncer.Factory
lateinit var account: Account
private lateinit var syncer: TestSyncer
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
@Test
fun testSync_prepare_fails() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns false
every { syncer.getSyncEnabledCollections() } returns emptyMap()
// Should stop the sync after prepare returns false
syncer.sync(provider)
verify(exactly = 1) { syncer.prepare(provider) }
verify(exactly = 0) { syncer.getSyncEnabledCollections() }
}
@Test
fun testSync_prepare_succeeds() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns true
every { syncer.getSyncEnabledCollections() } returns emptyMap()
// Should continue the sync after prepare returns true
syncer.sync(provider)
verify(exactly = 1) { syncer.prepare(provider) }
verify(exactly = 1) { syncer.getSyncEnabledCollections() }
}
@Test
fun testUpdateCollections_deletesCollection() {
val localCollection = mockk<LocalTestCollection>()
every { localCollection.collectionUrl } returns "http://delete.the/collection"
every { localCollection.deleteCollection() } returns true
every { localCollection.title } returns "Collection to be deleted locally"
// Should delete the localCollection if dbCollection (remote) does not exist
val localCollections = mutableListOf(localCollection)
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
verify(exactly = 1) { localCollection.deleteCollection() }
// Updated local collection list should be empty
assertTrue(result.isEmpty())
}
@Test
fun testUpdateCollections_updatesCollection() {
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
val dbCollections = mapOf("http://update.the/collection".toHttpUrl() to dbCollection)
every { dbCollection.url } returns "http://update.the/collection".toHttpUrl()
every { localCollection.collectionUrl } returns "http://update.the/collection"
every { localCollection.title } returns "The Local Collection"
// Should update the localCollection if it exists
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
// Updated local collection list should be same as input
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
}
@Test
fun testUpdateCollections_findsNewCollection() {
val dbCollection = mockk<Collection>()
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
val dbCollections = mapOf(dbCollection.url to dbCollection)
// Should return the new collection, because it was not updated
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
// Updated local collection list contain new entry
assertEquals(1, result.size)
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
}
@Test
fun testCreateLocalCollections() {
val provider = mockk<ContentProviderClient>()
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
every { syncer.create(provider, dbCollection) } returns localCollection
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
// Should return list of newly created local collections
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
assertEquals(listOf(localCollection), result)
}
@Test
fun testSyncCollectionContents() {
val provider = mockk<ContentProviderClient>()
val dbCollection1 = mockk<Collection>()
val dbCollection2 = mockk<Collection>()
val dbCollections = mapOf(
"http://newly.found/collection1".toHttpUrl() to dbCollection1,
"http://newly.found/collection2".toHttpUrl() to dbCollection2
)
val localCollection1 = mockk<LocalTestCollection>()
val localCollection2 = mockk<LocalTestCollection>()
val localCollections = listOf(localCollection1, localCollection2)
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
// Should call the collection content sync on both collections
syncer.syncCollectionContents(provider, localCollections, dbCollections)
verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) }
verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) }
}
// Test helpers
class TestSyncer @AssistedInject constructor(
@Assisted account: Account,
@Assisted extras: Array<String>,
@Assisted syncResult: SyncResult
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
@AssistedFactory
interface Factory {
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
}
override val authority: String
get() = ""
override val serviceType: String
get() = ""
override fun prepare(provider: ContentProviderClient): Boolean =
true
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
emptyList()
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
emptyList()
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
LocalTestCollection(remoteCollection.url.toString())
override fun syncCollection(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
remoteCollection: Collection
) {}
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
}
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.SyncResult
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response

View File

@@ -54,7 +54,7 @@ class AccountUtilsTest {
val manager = AccountManager.get(context)
try {
assertTrue(AccountUtils.createAccount(context, account, userData))
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
// validate user data
assertEquals("1", manager.getUserData(account, "int"))

View File

@@ -0,0 +1,152 @@
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountsCleanupWorkerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var settingsManager: SettingsManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var accountManager: AccountManager
lateinit var addressBookAccountType: String
lateinit var addressBookAccount: Account
lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
service = createTestService(Service.TYPE_CARDDAV)
// Prepare test account
accountManager = AccountManager.get(context)
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account(
"Fancy address book account",
addressBookAccountType
)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun tearDown() {
// Remove the account here in any case; Nice to have when the test fails
accountManager.removeAccountExplicitly(addressBookAccount)
}
@Test
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
// Create address book account without corresponding collection
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was deleted
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
}
@Test
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
// Create address book account _with_ corresponding collection and verify
val randomCollectionId = 12345L
val userData = Bundle(1).apply {
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
}
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
// Create the collection
val collectionDao = db.collectionDao()
collectionDao.insert(Collection(
randomCollectionId,
serviceId = service.id,
type = Collection.TYPE_ADDRESSBOOK,
url = "http://www.example.com/yay.php".toHttpUrl()
))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was _not_ deleted
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
}
// helpers
private fun createTestService(serviceType: String): Service {
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
val serviceId = db.serviceDao().insertOrReplace(service)
return db.serviceDao().get(serviceId)!!
}
}

View File

@@ -52,7 +52,7 @@ class TestAccountAuthenticator: Service() {
val accountType = context.getString(R.string.account_type_test)
val account = Account("Test Account", accountType)
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
return account
}

View File

@@ -1,54 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class OneTimeSyncWorkerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
@Test
fun testEnqueue_enqueuesWorker() {
OneTimeSyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
}

View File

@@ -17,7 +17,6 @@ import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.test.R
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -27,7 +26,6 @@ import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
@@ -69,21 +67,6 @@ class PeriodicSyncWorkerTest {
}
@Test
fun enable_enqueuesPeriodicWorker() {
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disable_removesPeriodicWorker() {
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
}
@Test
fun doWork_cancelsItselfOnInvalidAccount() {
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))

View File

@@ -0,0 +1,99 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncWorkerManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
// one-time sync workers
@Test
fun testEnqueueOneTime() {
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
assertEquals(workerName, returnedName)
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
// periodic sync workers
@Test
fun enablePeriodic() {
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disablePeriodic() {
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primaryColor">#E07C25</color>
<color name="primaryLightColor">#E5A371</color>
<color name="primaryDarkColor">#7C3E07</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
</resources>

View File

@@ -130,7 +130,9 @@
<intent-filter>
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data
tools:ignore="AppLinkUrlError"
android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
</activity>

View File

@@ -62,7 +62,7 @@ class App: Application(), Configuration.Provider {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Default) {
// clean up orphaned accounts in DB from time to time
AccountsCleanupWorker.enqueue(this@App)
AccountsCleanupWorker.enable(this@App)
// create/update app shortcuts
UiUtils.updateShortcuts(this@App)

View File

@@ -84,7 +84,7 @@ abstract class AppDatabase: RoomDatabase() {
// remove all accounts because they're unfortunately useless without database
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
am.removeAccount(account, null, null)
am.removeAccountExplicitly(account)
}
})
.build()

View File

@@ -72,11 +72,28 @@ data class Collection(
*/
var url: HttpUrl,
/**
* Whether we have the permission to change contents of the collection on the server.
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
*/
var privWriteContent: Boolean = true,
/**
* Whether we have the permission to delete the collection on the server
*/
var privUnbind: Boolean = true,
/**
* Whether the user has manually set the "force read-only" flag.
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
*/
var forceReadOnly: Boolean = false,
/**
* Human-readable name of the collection
*/
var displayName: String? = null,
/**
* Human-readable description of the collection
*/
var description: String? = null,
// CalDAV only

View File

@@ -72,6 +72,9 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL")
suspend fun getPushCapableSyncCollections(): List<Collection>
@Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync")
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@@ -85,7 +88,7 @@ interface CollectionDao {
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
suspend fun updatePushSubscription(id: Long, pushSubscription: String, updatedAt: Long = System.currentTimeMillis())
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
suspend fun updateSync(id: Long, sync: Boolean)

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.log
import com.google.common.base.Ascii
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
@@ -42,6 +43,11 @@ class PlainTextFormatter(
lineSeparator = System.lineSeparator()
)
/**
* Maximum length of a log line (estimate).
*/
const val MAX_LENGTH = 10000
fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), ".")
.replace(Regex("\\$.*$"), "")
@@ -72,7 +78,7 @@ class PlainTextFormatter(
}
}
builder.append(r.message)
builder.append(truncate(r.message))
if (withException && r.thrown != null) {
val indentedStackTrace = stackTrace(r.thrown)
@@ -82,8 +88,15 @@ class PlainTextFormatter(
}
r.parameters?.let {
for ((idx, param) in it.withIndex())
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
for ((idx, param) in it.withIndex()) {
builder.append("\n\tPARAMETER #").append(idx + 1).append(" = ")
val valStr = if (param == null)
"(null)"
else
truncate(param.toString())
builder.append(valStr)
}
}
if (lineSeparator != null)
@@ -92,4 +105,7 @@ class PlainTextFormatter(
return builder.toString()
}
private fun truncate(s: String) =
Ascii.truncate(s, MAX_LENGTH, "[…]")
}

View File

@@ -67,8 +67,9 @@ class DnsRecordResolver @Inject constructor(
val dnsServers = LinkedList<InetAddress>()
val connectivity = context.getSystemService<ConnectivityManager>()!!
@Suppress("DEPRECATION")
connectivity.allNetworks.forEach { network ->
val active = connectivity.getNetworkInfo(network)?.isConnected ?: false
val active = connectivity.getNetworkInfo(network)?.isConnected == true
connectivity.getLinkProperties(network)?.let { link ->
if (active)
// active connection, insert at top of list
@@ -129,12 +130,12 @@ class DnsRecordResolver @Inject constructor(
// Select records which have the minimum priority
val minPriority = srvRecords.minOfOrNull { it.priority }
val useableRecords = srvRecords.filter { it.priority == minPriority }
val usableRecords = srvRecords.filter { it.priority == minPriority }
.sortedBy { it.weight != 0 } // and put those with weight 0 first
val map = TreeMap<Int, SRVRecord>()
var runningWeight = 0
for (record in useableRecords) {
for (record in usableRecords) {
val weight = record.weight
runningWeight += weight
map[runningWeight] = record

View File

@@ -22,6 +22,21 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
import kotlinx.coroutines.flow.MutableStateFlow
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
@@ -36,23 +51,6 @@ import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
class HttpClient @AssistedInject constructor(
@Assisted val okHttpClient: OkHttpClient,
@@ -319,10 +317,7 @@ class HttpClient @AssistedInject constructor(
object UserAgentInterceptor: Interceptor {
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {

View File

@@ -0,0 +1,65 @@
package at.bitfire.davdroid.push
import android.accounts.Account
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.ui.account.AccountActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class PushNotificationManager @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationRegistry: NotificationRegistry
) {
/**
* Generates the notification ID for a push notification.
*/
private fun notificationId(account: Account, authority: String): Int {
return account.name.hashCode() + account.type.hashCode() + authority.hashCode()
}
/**
* Sends a notification to inform the user that a push notification has been received, the
* sync has been scheduled, but it still has not run.
*/
fun notify(account: Account, authority: String) {
notificationRegistry.notifyIfPossible(notificationId(account, authority)) {
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_sync)
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
.setContentText(context.getString(R.string.sync_notification_pending_push_message))
.setSubText(account.name)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
context,
0,
Intent(context, AccountActivity::class.java).apply {
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
.build()
}
}
/**
* Once the sync has been started, the notification is no longer needed and can be dismissed.
* It's safe to call this method even if the notification has not been shown.
*/
fun dismiss(account: Account, authority: String) {
NotificationManagerCompat.from(context)
.cancel(notificationId(account, authority))
}
}

View File

@@ -19,6 +19,7 @@ import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.push.NS_WEBDAV_PUSH
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
@@ -35,11 +36,13 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.StringWriter
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@@ -48,7 +51,8 @@ import javax.inject.Inject
* To be run as soon as a collection that supports push is changed (selected for sync status
* changes, or collection is created, deleted, etc).
*
* TODO Should run periodically, too. Not required for a first demonstration version.
* TODO Should run periodically, too (to refresh registrations that are about to expire).
* Not required for a first demonstration version.
*/
@Suppress("unused")
@HiltWorker
@@ -85,8 +89,17 @@ class PushRegistrationWorker @AssistedInject constructor(
}
private suspend fun requestPushRegistration(collection: Collection, account: Account, endpoint: String) {
val settings = accountSettingsFactory.forAccount(account)
override suspend fun doWork(): Result {
logger.info("Running push registration worker")
registerSyncable()
unregisterNotSyncable()
return Result.success()
}
private suspend fun registerPushSubscription(collection: Collection, account: Account, endpoint: String) {
val settings = accountSettingsFactory.create(account)
runInterruptible {
HttpClient.Builder(applicationContext, settings)
@@ -112,7 +125,7 @@ class PushRegistrationWorker @AssistedInject constructor(
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) runBlocking {
if (response.isSuccessful) {
response.header("Location")?.let { subscriptionUrl ->
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
}
@@ -123,23 +136,61 @@ class PushRegistrationWorker @AssistedInject constructor(
}
}
override suspend fun doWork(): Result {
logger.info("Running push registration worker")
private suspend fun registerSyncable() {
val endpoint = preferenceRepository.unifiedPushEndpoint()
// register push subscription for syncable collections
if (endpoint != null)
for (collection in collectionRepository.getSyncableAndPushCapable()) {
for (collection in collectionRepository.getPushCapableAndSyncable()) {
logger.info("Registering push for ${collection.url}")
val service = serviceRepository.get(collection.serviceId) ?: continue
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
requestPushRegistration(collection, account, endpoint)
serviceRepository.get(collection.serviceId)?.let { service ->
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
try {
registerPushSubscription(collection, account, endpoint)
} catch (e: DavException) {
// catch possible per-collection exception so that all collections can be processed
logger.log(Level.WARNING, "Couldn't register push for ${collection.url}", e)
}
}
}
else
logger.info("No UnifiedPush endpoint configured")
}
return Result.success()
private suspend fun unregisterPushSubscription(collection: Collection, account: Account, url: HttpUrl) {
val settings = accountSettingsFactory.create(account)
runInterruptible {
HttpClient.Builder(applicationContext, settings)
.setForeground(true)
.build()
.use { client ->
val httpClient = client.okHttpClient
try {
DavResource(httpClient, url).delete {
// deleted
}
} catch (e: DavException) {
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
}
// remove registration URL from DB in any case
collectionRepository.updatePushSubscription(collection.id, null)
}
}
}
private suspend fun unregisterNotSyncable() {
for (collection in collectionRepository.getPushRegisteredAndNotSyncable()) {
logger.info("Unregistering push for ${collection.url}")
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
serviceRepository.get(collection.serviceId)?.let { service ->
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
unregisterPushSubscription(collection, account, url)
}
}
}
}

View File

@@ -9,15 +9,15 @@ import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.AndroidEntryPoint
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
class UnifiedPushReceiver: MessagingReceiver() {
@@ -40,6 +40,9 @@ class UnifiedPushReceiver: MessagingReceiver() {
@Inject
lateinit var parsePushMessage: PushMessageParser
@Inject
lateinit var syncWorkerManager: SyncWorkerManager
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
// remember new endpoint
@@ -71,14 +74,14 @@ class UnifiedPushReceiver: MessagingReceiver() {
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
val account = accountRepository.fromName(service.accountName)
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
}
}
} else {
logger.warning("Got push message without topic, syncing all accounts")
for (account in accountRepository.getAll())
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
}
}

View File

@@ -4,16 +4,12 @@
package at.bitfire.davdroid.repository
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
@@ -27,10 +23,10 @@ import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.account.AccountUtils
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -56,6 +52,7 @@ class AccountRepository @Inject constructor(
private val logger: Logger,
private val settingsManager: SettingsManager,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
) {
@@ -63,7 +60,7 @@ class AccountRepository @Inject constructor(
private val accountManager = AccountManager.get(context)
/**
* Creates a new main account with discovered services and enables periodic syncs with
* Creates a new account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param accountName name of the account
@@ -80,13 +77,13 @@ class AccountRepository @Inject constructor(
val userData = AccountSettings.initialUserData(credentials)
logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to service DB
logger.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = accountSettingsFactory.forAccount(account)
val accountSettings = accountSettingsFactory.create(account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
@@ -135,17 +132,17 @@ class AccountRepository @Inject constructor(
}
suspend fun delete(accountName: String): Boolean {
// remove account
val future = accountManager.removeAccount(fromName(accountName), null, null, null)
val account = fromName(accountName)
// remove account directly (bypassing the authenticator, which is our own)
return try {
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
future.result
}
accountManager.removeAccountExplicitly(account)
// delete address book accounts
LocalAddressBook.deleteByAccount(context, accountName)
// delete address books (= address book accounts)
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
collectionRepository.getByService(service.id).forEach { collection ->
LocalAddressBook.deleteByCollection(context, collection.id)
}
}
// delete from database
serviceRepository.deleteByAccount(accountName)
@@ -174,7 +171,9 @@ class AccountRepository @Inject constructor(
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
accountManager.addOnAccountsUpdatedListener(listener, null, true)
withContext(Dispatchers.Default) { // causes disk I/O
accountManager.addOnAccountsUpdatedListener(listener, null, true)
}
awaitClose {
accountManager.removeOnAccountsUpdatedListener(listener)
@@ -203,7 +202,7 @@ class AccountRepository @Inject constructor(
throw IllegalArgumentException("Account with name \"$newName\" already exists")
// remember sync intervals
val oldSettings = accountSettingsFactory.forAccount(oldAccount)
val oldSettings = accountSettingsFactory.create(oldAccount)
val authorities = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
@@ -239,27 +238,12 @@ class AccountRepository @Inject constructor(
// disable periodic syncs for old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, oldAccount, authority)
syncWorkerManager.disablePeriodic(oldAccount, authority)
}
// update account name references in database
serviceRepository.renameAccount(oldName, newName)
// update main account of address book accounts
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED)
try {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, provider)
if (oldAccount == addressBook.mainAccount)
addressBook.mainAccount = Account(newName, oldAccount.type)
}
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't update address book accounts", e)
// Couldn't update address book accounts, but this is not a fatal error (will be fixed at next sync)
}
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again at next sync)
@@ -272,7 +256,7 @@ class AccountRepository @Inject constructor(
}
// restore sync intervals
val newSettings = accountSettingsFactory.forAccount(newAccount)
val newSettings = accountSettingsFactory.create(newAccount)
for ((authority, interval) in syncIntervals) {
if (interval == null)
ContentResolver.setIsSyncable(newAccount, authority, 0)

View File

@@ -6,14 +6,13 @@ package at.bitfire.davdroid.repository
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
@@ -29,22 +28,24 @@ import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.util.DateUtils
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import net.fortuna.ical4j.model.Component
import okhttp3.HttpUrl
import java.io.StringWriter
import java.util.Collections
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import net.fortuna.ical4j.model.Calendar
import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.ComponentList
import net.fortuna.ical4j.model.component.VTimeZone
import okhttp3.HttpUrl
/**
* Repository for managing collections.
@@ -134,7 +135,7 @@ class DavCollectionRepository @Inject constructor(
displayName = displayName,
description = description,
color = color,
timezoneDef = timeZoneId,
timezoneId = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
@@ -150,7 +151,7 @@ class DavCollectionRepository @Inject constructor(
displayName = displayName,
description = description,
color = color,
timezone = timeZoneId?.let { getVTimeZone(it) },
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
@@ -169,7 +170,7 @@ class DavCollectionRepository @Inject constructor(
val service = serviceRepository.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, context.getString(R.string.account_type))
HttpClient.Builder(context, accountSettingsFactory.forAccount(account))
HttpClient.Builder(context, accountSettingsFactory.create(account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
@@ -185,8 +186,14 @@ class DavCollectionRepository @Inject constructor(
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
fun get(id: Long) = dao.get(id)
fun getFlow(id: Long) = dao.getFlow(id)
fun getByService(serviceId: Long) = dao.getByService(serviceId)
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
fun getByServiceAndSync(serviceId: Long) = dao.getByServiceAndSync(serviceId)
fun getSyncCalendars(serviceId: Long) = dao.getSyncCalendars(serviceId)
@@ -196,9 +203,12 @@ class DavCollectionRepository @Inject constructor(
fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId)
/** Returns all collections that are both selected for synchronization and push-capable. */
suspend fun getSyncableAndPushCapable(): List<Collection> =
suspend fun getPushCapableAndSyncable(): List<Collection> =
dao.getPushCapableSyncCollections()
suspend fun getPushRegisteredAndNotSyncable(): List<Collection> =
dao.getPushRegisteredAndNotSyncable()
/**
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
* [Collection.forceReadOnly]), but use the values of the already existing collection.
@@ -246,7 +256,7 @@ class DavCollectionRepository @Inject constructor(
notifyOnChangeListeners()
}
suspend fun updatePushSubscription(id: Long, subscriptionUrl: String) {
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
dao.updatePushSubscription(id, subscriptionUrl)
}
@@ -262,7 +272,7 @@ class DavCollectionRepository @Inject constructor(
// helpers
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
HttpClient.Builder(context, accountSettingsFactory.forAccount(account))
HttpClient.Builder(context, accountSettingsFactory.create(account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
@@ -283,7 +293,7 @@ class DavCollectionRepository @Inject constructor(
displayName: String?,
description: String?,
color: Int? = null,
timezoneDef: String? = null,
timezoneId: String? = null,
supportsVEVENT: Boolean = true,
supportsVTODO: Boolean = true,
supportsVJOURNAL: Boolean = true
@@ -339,9 +349,17 @@ class DavCollectionRepository @Inject constructor(
text(DavUtils.ARGBtoCalDAVColor(it))
}
}
timezoneDef?.let {
insertTag(CalendarTimezone.NAME) {
cdsect(it)
timezoneId?.let { id ->
insertTag(CalendarTimezoneId.NAME) {
text(id)
}
getVTimeZone(id)?.let { vTimezone ->
insertTag(CalendarTimezone.NAME) {
text(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(ComponentList(listOf(vTimezone))).toString()
)
}
}
}
@@ -375,8 +393,7 @@ class DavCollectionRepository @Inject constructor(
return writer.toString()
}
private fun getVTimeZone(tzId: String): String? =
DateUtils.ical4jTimeZone(tzId)?.toString()
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
/*** OBSERVERS ***/

View File

@@ -9,11 +9,11 @@ import android.content.pm.PackageManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncStats
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.text.Collator
import java.util.logging.Logger
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class DavSyncStatsRepository @Inject constructor(
@ApplicationContext val context: Context,
@@ -62,7 +62,12 @@ class DavSyncStatsRepository @Inject constructor(
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
packageManager.getApplicationLabel(appInfo).toString()
if (appInfo != null) {
packageManager.getApplicationLabel(appInfo).toString()
} else {
logger.warning("Package name ($packageName) not found for authority: $authority")
authority
}
} catch (e: PackageManager.NameNotFoundException) {
logger.warning("Application name not found for authority: $authority")
authority

View File

@@ -17,164 +17,67 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.util.Base64
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.AccountUtils
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.Constants
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.ByteArrayOutputStream
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
/**
* A local address book. Requires an own Android account, because Android manages contacts per
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book. These accounts are bound to a
* DAVx5 main account.
* address book" account for every CardDAV address book.
*
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
* the new name will only be available in [addressBookAccount], so usually that one should be used.
*
* @param provider Content provider needed to access and modify the address book
*/
open class LocalAddressBook @Inject constructor(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
companion object {
private val logger
get() = Logger.getGlobal()
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
const val USER_DATA_URL = "url"
const val USER_DATA_READ_ONLY = "read_only"
/**
* Creates a local address book.
*
* @param context app context to resolve string resources
* @param provider contacts provider client
* @param mainAccount main account this address book (account) belongs to
* @param info collection where to take the name and settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
*/
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
val userData = initialUserData(mainAccount, info.url.toString())
logger.log(Level.INFO, "Creating local address book $account", userData)
if (!AccountUtils.createAccount(context, account, userData))
throw IllegalStateException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, account, provider)
addressBook.updateSyncFrameworkSettings()
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
return addressBook
}
fun deleteByAccount(context: Context, accountName: String) {
val mainAccount = Account(accountName, context.getString(R.string.account_type))
findAll(context, null, mainAccount).forEach {
it.delete()
}
}
/**
* Finds and returns all the local address books belonging to a given main account
*
* @param mainAccount the main account to use
* @return list of [mainAccount]'s address books
*/
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account) = AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter {
try {
it.mainAccount == mainAccount
} catch(e: IllegalArgumentException) {
false
}
}
.toList()
fun accountName(mainAccount: Account, info: Collection): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
info.url.lastSegment
else
it
})
sb.append(" (${mainAccount.name} $hash)")
return sb.toString()
}
fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
bundle.putString(USER_DATA_URL, url)
return bundle
}
/**
* Finds and returns the main account of the given address book's account (sub-account)
*
* @param account the address book account to find the main account for
*
* @return the associated main account, `null` if none can be found (e.g. when main account has been deleted)
*
* @throws IllegalArgumentException when [account] is not an address book account
*/
fun mainAccount(context: Context, account: Account): Account? =
if (account.type == context.getString(R.string.account_type_address_book)) {
val manager = AccountManager.get(context)
val accountName = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val accountType = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (accountName != null && accountType != null)
Account(accountName, accountType)
else
null
} else
throw IllegalArgumentException("$account is not an address book account")
@OpenForTesting
open class LocalAddressBook @AssistedInject constructor(
@Assisted _addressBookAccount: Account,
@Assisted provider: ContentProviderClient,
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext val context: Context,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
@AssistedFactory
interface Factory {
fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface LocalAddressBookEntryPoint {
fun accountSettingsFactory(): AccountSettings.Factory
}
private val entryPoint = EntryPointAccessors.fromApplication(context, LocalAddressBookEntryPoint::class.java)
private val accountSettingsFactory = entryPoint.accountSettingsFactory()
override val tag: String
get() = "contacts-${account.name}"
get() = "contacts-${addressBookAccount.name}"
override val title = account.name!!
override val title
get() = addressBookAccount.name
/**
* Whether contact groups ([LocalGroup]) are included in query results
@@ -184,48 +87,31 @@ open class LocalAddressBook @Inject constructor(
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
open val groupMethod: GroupMethod by lazy {
val accountSettings = accountSettingsFactory.forAccount(requireMainAccount())
val manager = AccountManager.get(context)
val account = manager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
}
if (account == null)
throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account")
val accountSettings = accountSettingsFactory.create(account)
accountSettings.getGroupMethod()
}
val includeGroups
private val includeGroups
get() = groupMethod == GroupMethod.GROUP_VCARDS
private var _mainAccount: Account? = null
/**
* The associated main account which this address book's accounts belong to.
*
* @throws IllegalArgumentException when [account] is not an address book account or when no main account is assigned
*/
open var mainAccount: Account?
get() {
_mainAccount?.let { return it }
val result = mainAccount(context, account)
_mainAccount = result
return result
}
set(newMainAccount) {
if (newMainAccount == null)
throw IllegalArgumentException("Main account must not be null")
AccountManager.get(context).let { accountManager ->
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
}
_mainAccount = newMainAccount
}
fun requireMainAccount(): Account =
mainAccount ?: throw IllegalArgumentException("No main account assigned to address book $account")
override var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
@Deprecated("Local collection should be identified by ID, not by URL")
override var collectionUrl: String
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_URL, url)
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
@@ -265,48 +151,104 @@ open class LocalAddressBook @Inject constructor(
* Updates the address book settings.
*
* @param info collection where to take the settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
* @param forceReadOnly `true`: set the address book to "force read-only";
* `false`: determine read-only flag from [info];
* `null`: don't change the existing value
*/
fun update(info: Collection, forceReadOnly: Boolean) {
val newAccountName = accountName(requireMainAccount(), info)
fun update(info: Collection, forceReadOnly: Boolean? = null) {
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
val accountManager = AccountManager.get(context)
if (account.name != newAccountName) {
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, null, null)
account = future.result
}
// Update the account name
val newAccountName = accountName(context, info)
if (addressBookAccount.name != newAccountName)
// rename, move contacts/groups and update [AndroidAddressBook.]account
renameAccount(newAccountName)
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
// Update the account user data
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
// update address book itself
readOnly = nowReadOnly
// Update force read only
if (forceReadOnly != null) {
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
logger.info("Address book now read-only = $nowReadOnly, updating contacts")
// update raw contacts
val rawContactValues = ContentValues(1)
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update address book itself
readOnly = nowReadOnly
// update data rows
val dataValues = ContentValues(1)
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update raw contacts
val rawContactValues = ContentValues(1)
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update group rows
val groupValues = ContentValues(1)
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
// update data rows
val dataValues = ContentValues(1)
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update group rows
val groupValues = ContentValues(1)
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
}
}
// make sure it will still be synchronized when contacts are updated
updateSyncFrameworkSettings()
}
override fun delete(): Boolean {
/**
* Renames an address book account and moves the contacts and groups (without making them dirty).
* Does not keep user data of the old account, so these have to be set again.
*
* On success, [addressBookAccount] will be updated to the new account name.
*
* _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never
* a moment when both accounts are available.
*
* @param newName the new account name (account type is taken from [addressBookAccount])
*
* @return whether the account was renamed successfully
*/
@VisibleForTesting
internal fun renameAccount(newName: String): Boolean {
val oldAccount = addressBookAccount
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
// create new account
val newAccount = Account(newName, oldAccount.type)
if (!SystemAccountUtils.createAccount(context, newAccount, Bundle()))
return false
// move contacts and groups to new account
val batch = BatchOperation(provider!!)
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(groupsSyncUri())
.withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(Groups.ACCOUNT_NAME, newAccount.name)
)
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(rawContactsSyncUri())
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
)
batch.commit()
// update AndroidAddressBook.account
addressBookAccount = newAccount
// delete old account
val accountManager = AccountManager.get(context)
return accountManager.removeAccountExplicitly(account)
accountManager.removeAccountExplicitly(oldAccount)
return true
}
override fun deleteCollection(): Boolean {
val accountManager = AccountManager.get(context)
return accountManager.removeAccountExplicitly(addressBookAccount)
}
@@ -320,15 +262,15 @@ open class LocalAddressBook @Inject constructor(
*/
fun updateSyncFrameworkSettings() {
// Enable sync-ability
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
// Enable content trigger
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
if (!ContentResolver.getSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY, true)
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
for (periodicSync in ContentResolver.getPeriodicSyncs(addressBookAccount, ContactsContract.AUTHORITY))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
}
@@ -466,4 +408,129 @@ open class LocalAddressBook @Inject constructor(
}
}
companion object {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface LocalAddressBookCompanionEntryPoint {
fun localAddressBookFactory(): Factory
fun serviceRepository(): DavServiceRepository
fun logger(): Logger
}
const val USER_DATA_URL = "url"
const val USER_DATA_COLLECTION_ID = "collection_id"
const val USER_DATA_READ_ONLY = "read_only"
// create/query/delete
/**
* Creates a new local address book.
*
* @param context app context to resolve string resources
* @param provider contacts provider client
* @param info collection where to take the name and settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
*/
fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val logger = entryPoint.logger()
val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book))
val userData = initialUserData(info.url.toString(), info.id.toString())
logger.log(Level.INFO, "Creating local address book $account", userData)
if (!SystemAccountUtils.createAccount(context, account, userData))
throw IllegalStateException("Couldn't create address book account")
val factory = entryPoint.localAddressBookFactory()
val addressBook = factory.create(account, provider)
addressBook.updateSyncFrameworkSettings()
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
return addressBook
}
/**
* Finds a [LocalAddressBook] based on its corresponding collection.
*
* @param id collection ID to look for
*
* @return The [LocalAddressBook] for the given collection or *null* if not found
*/
fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? {
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val factory = entryPoint.localAddressBookFactory()
val accountManager = AccountManager.get(context)
return accountManager
.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { account ->
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
.map { account -> factory.create(account, provider) }
.firstOrNull()
}
/**
* Deletes a [LocalAddressBook] based on its corresponding database collection.
*
* @param id collection ID to look for
*/
fun deleteByCollection(context: Context, id: Long) {
val accountManager = AccountManager.get(context)
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
if (addressBookAccount != null)
accountManager.removeAccountExplicitly(addressBookAccount)
}
// helpers
/**
* Creates a name for the address book account from its corresponding db collection info.
*
* The address book account name contains
* - the collection display name or last URL path segment
* - the actual account name
* - the collection ID, to make it unique.
*
* @param info The corresponding collection
*/
fun accountName(context: Context, info: Collection): String {
// Name the address book after given collection display name, otherwise use last URL path segment
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
info.url.lastSegment
else
it
})
// Add the actual account name to the address book account name
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val serviceRepository = entryPoint.serviceRepository()
serviceRepository.get(info.serviceId)?.let { service ->
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
sb.append(" #${info.id}")
return sb.toString()
}
private fun initialUserData(url: String, collectionId: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_COLLECTION_ID, collectionId)
bundle.putString(USER_DATA_URL, url)
return bundle
}
}
}

View File

@@ -43,7 +43,11 @@ class LocalCalendar private constructor(
get() = Logger.getGlobal()
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollectionInfo(info, withColor = true)
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name)
@@ -65,8 +69,8 @@ class LocalCalendar private constructor(
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
@@ -94,7 +98,7 @@ class LocalCalendar private constructor(
}
override val url: String?
override val collectionUrl: String?
get() = name
override val tag: String
@@ -107,6 +111,8 @@ class LocalCalendar private constructor(
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
override fun deleteCollection(): Boolean = delete()
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
@@ -127,11 +133,11 @@ class LocalCalendar private constructor(
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()

View File

@@ -14,7 +14,7 @@ interface LocalCollection<out T: LocalResource<*>> {
/** Address of the remote collection */
@Deprecated("Local collection should be identified by ID, not by URL")
val url: String?
val collectionUrl: String?
/** collection title (used for user notifications etc.) **/
val title: String
@@ -32,7 +32,7 @@ interface LocalCollection<out T: LocalResource<*>> {
*
* @return true if the collection was deleted, false otherwise
*/
fun delete(): Boolean
fun deleteCollection(): Boolean
/**
* Finds local resources of this collection which have been marked as *deleted* by the user

View File

@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.net.Uri
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
@@ -24,9 +25,14 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
companion object {
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) {
val values = valuesFromCollection(info, account, owner, true)
create(account, client, values)
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri {
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollection(info, account, owner, withColor = true)
return create(account, client, values)
}
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
@@ -42,8 +48,8 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
else
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
if (withColor)
put(JtxContract.JtxCollection.COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (withColor && info.color != null)
put(JtxContract.JtxCollection.COLOR, info.color)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
@@ -56,16 +62,20 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = delete()
override val tag: String
get() = "jtx-${account.name}-$id"
override val collectionUrl: String?
get() = url
override val title: String
get() = displayname ?: id.toString()
override var lastSyncState: SyncState?
get() = SyncState.fromString(syncstate)
set(value) { syncstate = value.toString() }
fun updateCollection(info: Collection, owner: Principal?, withColor: Boolean) {
val values = valuesFromCollection(info, account, owner, withColor)
fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) {
val values = valuesFromCollection(info, account, owner, updateColor)
update(values)
}

View File

@@ -65,7 +65,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
// update in tasks provider
val values = ContentValues(1)
values.put(Tasks._UID, newUid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
taskList.provider.update(taskSyncURI(), values, null, null)
// update this task
task!!.uid = newUid
@@ -86,7 +86,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
values.put(Tasks._DIRTY, 0)
taskList.provider.client.update(taskSyncURI(), values, null, null)
taskList.provider.update(taskSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
@@ -97,7 +97,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
taskList.provider.client.update(taskSyncURI(), values, null, null)
taskList.provider.update(taskSyncURI(), values, null, null)
}
this.flags = flags

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
import android.net.Uri
@@ -27,18 +28,23 @@ import java.util.logging.Logger
*/
class LocalTaskList private constructor(
account: Account,
provider: TaskProvider,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
): DmfsTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
companion object {
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri {
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollectionInfo(info, withColor = true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
values.put(TaskLists.VISIBLE, 1)
return create(account, provider, values)
return create(account, provider, providerName, values)
}
@SuppressLint("Recycle")
@@ -61,8 +67,8 @@ class LocalTaskList private constructor(
values.put(TaskLists.LIST_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor)
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (withColor && info.color != null)
values.put(TaskLists.LIST_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly)
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
@@ -82,7 +88,9 @@ class LocalTaskList private constructor(
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
override val url: String?
override fun deleteCollection(): Boolean = delete()
override val collectionUrl: String?
get() = syncId
override val tag: String
@@ -94,7 +102,7 @@ class LocalTaskList private constructor(
override var lastSyncState: SyncState?
get() {
try {
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
@@ -109,7 +117,7 @@ class LocalTaskList private constructor(
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
provider.client.update(taskListSyncUri(), values, null, null)
provider.update(taskListSyncUri(), values, null, null)
}
@@ -148,28 +156,32 @@ class LocalTaskList private constructor(
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalTask.COLUMN_FLAGS, flags)
return provider.client.update(tasksSyncUri(), values,
return provider.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.client.delete(tasksSyncUri(),
provider.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}
object Factory: DmfsTaskListFactory<LocalTaskList> {
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
LocalTaskList(account, provider, id)
override fun newInstance(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
) = LocalTaskList(account, provider, providerName, id)
}

View File

@@ -15,8 +15,6 @@ import java.io.FileNotFoundException
import java.util.logging.Logger
class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
private val logger: Logger = Logger.getGlobal()
override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE

View File

@@ -181,7 +181,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
// create authenticating OkHttpClient (credentials taken from account settings)
runInterruptible {
HttpClient.Builder(applicationContext, accountSettingsFactory.forAccount(account))
HttpClient.Builder(applicationContext, accountSettingsFactory.create(account))
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient

View File

@@ -8,14 +8,14 @@ import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import android.os.Looper
import android.provider.CalendarContract
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.ical4android.TaskProvider
@@ -31,140 +31,58 @@ import java.util.logging.Logger
/**
* Manages settings of an account.
*
* @param accountOrAddressBookAccount Account to take settings from. If this account is an address book account,
* settings will be taken from the corresponding main account instead.
* Must not be called from main thread as it uses blocking I/O
* and may run migrations.
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
* @throws IllegalArgumentException when the account type is not _DAVx5_ or _DAVx5 address book_
* @param account account to take settings from
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
* @throws IllegalArgumentException when the account is not a DAVx5 account
*/
@WorkerThread
class AccountSettings @AssistedInject constructor(
@Assisted accountOrAddressBookAccount: Account,
@Assisted val account: Account,
@ApplicationContext val context: Context,
private val logger: Logger,
private val migrationsFactory: AccountSettingsMigrations.Factory,
private val settingsManager: SettingsManager
private val settingsManager: SettingsManager,
private val syncWorkerManager: SyncWorkerManager
) {
@AssistedFactory
interface Factory {
fun forAccount(account: Account): AccountSettings
}
companion object {
const val CURRENT_VERSION = 16
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
/** OAuth [AuthState] (serialized as JSON) */
const val KEY_AUTH_STATE = "auth_state"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
/** Time range limitation to the past [in days]. Values:
*
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
* - <0 (typically -1): no limit
* - n>0: entries more than n days in the past won't be synchronized
*/
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/**
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
* Value can be null (no default alarm) or an integer (default alarm shall be created this
* number of minutes before the event/task).
* Must not be called from main thread as AccountSettings uses blocking I/O and may run
* migrations.
*/
const val KEY_DEFAULT_ALARM = "default_alarm"
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
value = *null* (not existing): true (default);
"0" false */
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/** Whether DAVx5 populates and uses CalendarContract.Colors
value = *null* (not existing) false (default);
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
*null (not existing)* groups as separate vCards (default);
"CATEGORIES" groups are per-contact CATEGORIES
*/
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
/** UI preference: Show only personal collections
value = *null* (not existing) show all collections (default);
"1" show only personal collections */
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
const val SYNC_INTERVAL_MANUALLY = -1L
/** Static property to indicate whether AccountSettings migration is currently running.
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
@Volatile
var currentlyUpdating = false
fun initialUserData(credentials: Credentials?): Bundle {
val bundle = Bundle()
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.username != null)
bundle.putString(KEY_USERNAME, credentials.username)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
if (credentials.authState != null)
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
}
return bundle
}
@WorkerThread
fun create(account: Account): AccountSettings
}
val accountManager: AccountManager = AccountManager.get(context)
val account: Account = when (accountOrAddressBookAccount.type) {
context.getString(R.string.account_type_address_book) -> {
/* argument is an address book account, which is not a main account. However settings are
stored in the main account, so resolve and use the main account instead. */
LocalAddressBook.mainAccount(context, accountOrAddressBookAccount) ?: throw IllegalArgumentException("Main account of $accountOrAddressBookAccount not found")
}
context.getString(R.string.account_type),
"at.bitfire.davdroid.test" /* defined in androidTest/strings/account_type_test */ ->
accountOrAddressBookAccount
else ->
throw IllegalArgumentException("Account type ${accountOrAddressBookAccount.type} not supported")
}
init {
if (Looper.getMainLooper() == Looper.myLooper())
throw IllegalThreadStateException("AccountSettings may not be used on main thread")
}
val accountManager: AccountManager = AccountManager.get(context)
init {
val allowedAccountTypes = arrayOf(
context.getString(R.string.account_type),
"at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest
)
if (!allowedAccountTypes.contains(account.type))
throw IllegalArgumentException("Invalid account type: ${account.type}")
// synchronize because account migration must only be run one time
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(this.account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(
this.account
)
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
}
logger.fine("Account ${this.account.name} has version $version, current version: $CURRENT_VERSION")
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION) {
if (currentlyUpdating) {
@@ -369,10 +287,10 @@ class AccountSettings @AssistedInject constructor(
try {
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
logger.fine("Disabling periodic sync of $account/$authority")
PeriodicSyncWorker.disable(context, account, authority)
syncWorkerManager.disablePeriodic(account, authority)
} else {
logger.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
PeriodicSyncWorker.enable(context, account, authority, seconds, wiFiOnly)
syncWorkerManager.enablePeriodic(account, authority, seconds, wiFiOnly)
}.result.get() // On operation (enable/disable) failure exception is thrown
} catch (e: Exception) {
logger.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
@@ -517,4 +435,89 @@ class AccountSettings @AssistedInject constructor(
}
}
companion object {
const val CURRENT_VERSION = 17
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
/** OAuth [AuthState] (serialized as JSON) */
const val KEY_AUTH_STATE = "auth_state"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
/** Time range limitation to the past [in days]. Values:
*
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
* - <0 (typically -1): no limit
* - n>0: entries more than n days in the past won't be synchronized
*/
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/**
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
* Value can be null (no default alarm) or an integer (default alarm shall be created this
* number of minutes before the event/task).
*/
const val KEY_DEFAULT_ALARM = "default_alarm"
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
value = *null* (not existing): true (default);
"0" false */
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/** Whether DAVx5 populates and uses CalendarContract.Colors
value = *null* (not existing) false (default);
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
*null (not existing)* groups as separate vCards (default);
"CATEGORIES" groups are per-contact CATEGORIES
*/
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
/** UI preference: Show only personal collections
value = *null* (not existing) show all collections (default);
"1" show only personal collections */
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
const val SYNC_INTERVAL_MANUALLY = -1L
/** Static property to indicate whether AccountSettings migration is currently running.
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
@Volatile
var currentlyUpdating = false
fun initialUserData(credentials: Credentials?): Bundle {
val bundle = Bundle()
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.username != null)
bundle.putString(KEY_USERNAME, credentials.username)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
if (credentials.authState != null)
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
}
return bundle
}
}
}

View File

@@ -12,8 +12,6 @@ import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.os.Parcel
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Base64
@@ -22,21 +20,20 @@ import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Lazy
import dagger.assisted.Assisted
@@ -45,7 +42,6 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.property.Url
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.dmfs.tasks.contract.TaskContract
import java.io.ByteArrayInputStream
import java.io.ObjectInputStream
@@ -56,8 +52,13 @@ class AccountSettingsMigrations @AssistedInject constructor(
@Assisted val account: Account,
@Assisted val accountSettings: AccountSettings,
@ApplicationContext val context: Context,
private val accountRepository: AccountRepository,
private val collectionRepository: DavCollectionRepository,
private val db: AppDatabase,
private val localAddressBookFactory: LocalAddressBook.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
) {
@@ -66,9 +67,43 @@ class AccountSettingsMigrations @AssistedInject constructor(
fun create(account: Account, accountSettings: AccountSettings): AccountSettingsMigrations
}
val accountManager: AccountManager = AccountManager.get(context)
/**
* With DAVx5 4.3.3 address book account names now contain the collection ID as a unique
* identifier. We need to update the address book account names.
*/
@Suppress("unused","FunctionName")
fun update_16_17() {
val addressBookAccountType = context.getString(R.string.account_type_address_book)
try {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
} catch (e: SecurityException) {
// Not setting the collection ID will cause the address books to removed and fully re-synced as soon as there are permissions.
logger.log(Level.WARNING, "Missing permissions for contacts authority, won't set collection ID for address books", e)
null
}?.use { provider ->
val service = serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV) ?: return
// Get all old address books of this account, i.e. the ones which have a "real_account_name" of this account.
// After this migration is run, address books won't be associated to accounts anymore but only to their respective collection/URL.
val oldAddressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
.filter { addressBookAccount ->
account.name == accountManager.getUserData(addressBookAccount, "real_account_name")
}
for (oldAddressBookAccount in oldAddressBookAccounts) {
// Old address books only have a URL, so use it to determine the collection ID
logger.info("Migrating address book ${oldAddressBookAccount.name}")
val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL)
collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection ->
// Set collection ID and rename the account
val localAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider)
localAddressBook.update(collection)
}
}
}
}
/**
* Between DAVx5 4.4.1-beta.1 and 4.4.1-rc.1 (both v15), the periodic sync workers were renamed (moved to another
@@ -83,7 +118,7 @@ class AccountSettingsMigrations @AssistedInject constructor(
/* A maybe existing periodic worker references the old class name (even if it failed and/or is not active). So
we need to explicitly disable and prune all workers. Just updating the worker is not enough WorkManager will update
the work details, but not the class name. */
val disableOp = PeriodicSyncWorker.disable(context, account, authority)
val disableOp = syncWorkerManager.disablePeriodic(account, authority)
disableOp.result.get() // block until worker with old name is disabled
val pruneOp = WorkManager.getInstance(context).pruneWork()
@@ -93,13 +128,7 @@ class AccountSettingsMigrations @AssistedInject constructor(
if (interval != null && interval != AccountSettings.SYNC_INTERVAL_MANUALLY) {
// There's a sync interval for this account/authority; a periodic sync worker should be there, too.
val onlyWifi = accountSettings.getSyncWifiOnly()
PeriodicSyncWorker.enable(
context,
account,
authority,
interval,
onlyWifi
)
syncWorkerManager.enablePeriodic(account, authority, interval, onlyWifi)
}
}
}
@@ -389,84 +418,6 @@ class AccountSettingsMigrations @AssistedInject constructor(
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
}
@Suppress("unused")
@SuppressLint("ParcelClassLoader")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
val parcel = Parcel.obtain()
try {
// don't run syncs during the migration
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
ContentResolver.cancelSync(account, null)
// get previous address book settings (including URL)
val raw = ContactsContract.SyncState.get(provider, account)
if (raw == null)
logger.info("No contacts sync state, ignoring account")
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()!!
val url = params.getString("url")?.toHttpUrlOrNull()
if (url == null)
logger.info("No address book URL, ignoring account")
else {
// create new address book
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
logger.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(
LocalAddressBook.accountName(account, info), context.getString(
R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
logger.info("Moving contacts from $account to $addressBookAccount")
val newAccount = ContentValues(2)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
val affected = provider.update(
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
newAccount,
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
arrayOf(account.name, account.type))
logger.info("$affected contacts moved to new address book")
}
ContactsContract.SyncState.set(provider, account, null)
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
}
}
// update version number so that further syncs don't repeat the migration
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), 4*3600)
}
/* Android 7.1.1 OpenTasks fix */
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
val manager = tasksAppManager.get()
manager.selectProvider(manager.currentProvider())
}
@Suppress("unused")
private fun update_3_4() {
accountSettings.setGroupMethod(GroupMethod.CATEGORIES)
}
// updates from AccountSettings version 2 and below are not supported anymore
// updates from AccountSettings version 5 and below are not supported anymore
}

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.startup
import android.content.Context
import android.os.Build
import android.os.StrictMode
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.startup.StartupPlugin.Companion.PRIORITY_DEFAULT
@@ -50,17 +51,23 @@ class CrashHandlerSetup @Inject constructor(
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyFlashScreen()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
val builder = StrictMode.VmPolicy.Builder() // don't use detectAll() because it causes "untagged socket" warnings
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
builder.detectContentUriWithoutPermission()
/*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) // often triggered by Conscrypt
builder.detectNonSdkApiUsage()*/
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
builder.detectUnsafeIntentLaunch()
StrictMode.setVmPolicy(builder.penaltyLog().build())
} else {
// release build

View File

@@ -6,7 +6,6 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.SyncResult
import android.provider.ContactsContract
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
@@ -48,10 +47,15 @@ class AddressBookSyncer @AssistedInject constructor(
get() = ContactsContract.AUTHORITY // Address books use the contacts authority for sync
override fun localSyncCollections(provider: ContentProviderClient): List<LocalAddressBook>
= LocalAddressBook.findAll(context, provider, account)
override fun getLocalCollections(provider: ContentProviderClient): List<LocalAddressBook> =
serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service ->
// Get _all_ address books; Otherwise address book accounts of unchecked address books will not be removed
collectionRepository.getByService(service.id).mapNotNull { collection ->
LocalAddressBook.findByCollection(context, provider, collection.id)
}
}.orEmpty()
override fun getSyncCollections(serviceId: Long): List<Collection> =
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
collectionRepository.getByServiceAndSync(serviceId)
override fun update(localCollection: LocalAddressBook, remoteCollection: Collection) {
@@ -63,25 +67,37 @@ class AddressBookSyncer @AssistedInject constructor(
}
}
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalAddressBook {
logger.log(Level.INFO, "Adding local address book", remoteCollection)
LocalAddressBook.create(context, provider, account, remoteCollection, forceAllReadOnly)
return LocalAddressBook.create(context, provider, remoteCollection, forceAllReadOnly)
}
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalAddressBook, remoteCollection: Collection) {
logger.info("Synchronizing address book $localCollection")
logger.info("Synchronizing address book: ${localCollection.addressBookAccount.name}")
syncAddressBook(
localCollection.account,
extras,
httpClient,
provider,
syncResult,
remoteCollection
account = account,
addressBook = localCollection,
extras = extras,
httpClient = httpClient,
provider = provider,
syncResult = syncResult,
collection = remoteCollection
)
}
/**
* Synchronizes an address book
*
* @param addressBook local address book
* @param extras Sync specific instructions. IE [Syncer.SYNC_EXTRAS_FULL_RESYNC]
* @param httpClient
* @param provider Content provider to access android contacts
* @param syncResult Stores hard and soft sync errors
* @param collection The database collection associated with this address book
*/
private fun syncAddressBook(
account: Account,
addressBook: LocalAddressBook,
extras: Array<String>,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
@@ -89,12 +105,11 @@ class AddressBookSyncer @AssistedInject constructor(
collection: Collection
) {
try {
val accountSettings = accountSettingsFactory.forAccount(account)
val addressBook = LocalAddressBook(context, account, provider)
val accountSettings = accountSettingsFactory.create(account)
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
accountSettings.accountManager.getUserData(addressBook.addressBookAccount, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
if (previousGroupMethod != groupMethod) {
logger.info("Group method changed, deleting all local contacts/groups")
@@ -106,10 +121,7 @@ class AddressBookSyncer @AssistedInject constructor(
addressBook.syncState = null
}
}
accountSettings.accountManager.setAndVerifyUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
logger.info("Synchronizing address book: ${addressBook.url}")
logger.info("Taking settings from: ${addressBook.mainAccount}")
accountSettings.accountManager.setAndVerifyUserData(addressBook.addressBookAccount, PREVIOUS_GROUP_METHOD, groupMethod)
val syncManager = contactsSyncManagerFactory.contactsSyncManager(account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook, collection)
syncManager.performSync()

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.SyncResult
import android.text.format.Formatter
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
@@ -196,20 +195,35 @@ class CalendarSyncManager @AssistedInject constructor(
logger.info("Downloading ${bunch.size} iCalendars: $bunch")
SyncException.wrapWithRemoteResource(collection.url) {
davCollection.multiget(bunch) { response, _ ->
SyncException.wrapWithRemoteResource(response.href) wrapResponse@ {
/*
* Real-world servers may return:
*
* - unrelated resources
* - the collection itself
* - the requested resources, but with a different collection URL (for instance, `/cal/1.ics` instead of `/shared-cal/1.ics`).
*
* So we:
*
* - ignore unsuccessful responses,
* - ignore responses without requested calendar data (should also ignore collections and hopefully unrelated resources), and
* - take the last segment of the href as the file name and assume that it's in the requested collection.
*/
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
if (!response.isSuccess()) {
logger.warning("Received non-successful multiget response for ${response.href}")
return@wrapResponse
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
return@wrapResource
}
val iCal = response[CalendarData::class.java]?.iCalendar
if (iCal == null) {
logger.warning("Ignoring multi-get response without calendar-data")
return@wrapResource
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
?: throw DavException("Received multi-get response without ETag")
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without calendar data")
processVEvent(
response.href.lastSegment,
eTag,

View File

@@ -6,7 +6,7 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.SyncResult
import android.content.ContentUris
import android.provider.CalendarContract
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
@@ -38,7 +38,7 @@ class CalendarSyncer @AssistedInject constructor(
get() = CalendarContract.AUTHORITY
override fun localSyncCollections(provider: ContentProviderClient): List<LocalCalendar>
override fun getLocalCollections(provider: ContentProviderClient): List<LocalCalendar>
= AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
override fun prepare(provider: ContentProviderClient): Boolean {
@@ -50,7 +50,7 @@ class CalendarSyncer @AssistedInject constructor(
return true
}
override fun getSyncCollections(serviceId: Long): List<Collection> =
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
collectionRepository.getSyncCalendars(serviceId)
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalCalendar, remoteCollection: Collection) {
@@ -74,9 +74,10 @@ class CalendarSyncer @AssistedInject constructor(
localCollection.update(remoteCollection, accountSettings.getManageCalendarColors())
}
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalCalendar {
logger.log(Level.INFO, "Adding local calendar", remoteCollection)
LocalCalendar.create(account, provider, remoteCollection)
val uri = LocalCalendar.create(account, provider, remoteCollection)
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
}

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.SyncResult
import android.os.Build
import android.text.format.Formatter
import at.bitfire.dav4jvm.DavAddressBook
@@ -320,24 +319,27 @@ class ContactsSyncManager @AssistedInject constructor(
}
}
davCollection.multiget(bunch, contentType, version) { response, _ ->
// See CalendarSyncManager for more information about the multi-get response
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
if (!response.isSuccess()) {
logger.warning("Received non-successful multiget response for ${response.href}")
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
return@wrapResource
}
val card = response[AddressData::class.java]?.card
if (card == null) {
logger.warning("Ignoring multi-get response without address-data")
return@wrapResource
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
?: throw DavException("Received multi-get response without ETag")
var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it)
response[GetContentType::class.java]?.type?.let { type ->
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
}
val addressData = response[AddressData::class.java]
val card = addressData?.card
?: throw DavException("Received multi-get response without address data")
processCard(
response.href.lastSegment,
eTag,

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.SyncResult
import android.text.format.Formatter
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
@@ -123,19 +122,22 @@ class JtxSyncManager @AssistedInject constructor(
// multiple iCalendars, use calendar-multi-get
SyncException.wrapWithRemoteResource(collection.url) {
davCollection.multiget(bunch) { response, _ ->
// See CalendarSyncManager for more information about the multi-get response
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
if (!response.isSuccess()) {
logger.warning("Received non-successful multiget response for ${response.href}")
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
return@wrapResource
}
val iCal = response[CalendarData::class.java]?.iCalendar
if (iCal == null) {
logger.warning("Ignoring multi-get response without calendar-data")
return@wrapResource
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
processICalObject(response.href.lastSegment, eTag, StringReader(iCal))
}
}

View File

@@ -7,7 +7,7 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.SyncResult
import android.content.ContentUris
import android.os.Build
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
@@ -15,6 +15,7 @@ import at.bitfire.davdroid.repository.PrincipalRepository
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -43,7 +44,7 @@ class JtxSyncer @AssistedInject constructor(
get() = TaskProvider.ProviderName.JtxBoard.authority
override fun localSyncCollections(provider: ContentProviderClient): List<LocalJtxCollection>
override fun getLocalCollections(provider: ContentProviderClient): List<LocalJtxCollection>
= JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
override fun prepare(provider: ContentProviderClient): Boolean {
@@ -52,6 +53,7 @@ class JtxSyncer @AssistedInject constructor(
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
} catch (e: TaskProvider.ProviderTooOldException) {
tasksAppManager.get().notifyProviderTooOld(e)
syncResult.contentProviderError = true
return false // Don't sync
}
@@ -67,7 +69,7 @@ class JtxSyncer @AssistedInject constructor(
return true
}
override fun getSyncCollections(serviceId: Long): List<Collection> =
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
collectionRepository.getSyncJtxCollections(serviceId)
override fun update(localCollection: LocalJtxCollection, remoteCollection: Collection) {
@@ -76,10 +78,18 @@ class JtxSyncer @AssistedInject constructor(
localCollection.updateCollection(remoteCollection, owner, accountSettings.getManageCalendarColors())
}
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalJtxCollection {
logger.log(Level.INFO, "Adding local jtx collection", remoteCollection)
val owner = remoteCollection.ownerId?.let { principalRepository.get(it) }
LocalJtxCollection.create(account, provider, remoteCollection, owner)
val uri = LocalJtxCollection.create(account, provider, remoteCollection, owner)
return JtxCollection.find(
account,
provider,
context,
LocalJtxCollection.Factory,
"${JtxContract.JtxCollection.ID} = ?",
arrayOf("${ContentUris.parseId(uri)}")
).first()
}
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalJtxCollection, remoteCollection: Collection) {

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Service
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
@@ -18,13 +19,18 @@ import android.provider.ContactsContract
import androidx.work.WorkManager
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.logging.Level
@@ -52,9 +58,12 @@ abstract class SyncAdapterService: Service() {
*/
class SyncAdapter @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
private val serviceRepository: DavServiceRepository,
@ApplicationContext context: Context,
private val logger: Logger,
private val syncConditionsFactory: SyncConditions.Factory
private val syncConditionsFactory: SyncConditions.Factory,
private val syncWorkerManager: SyncWorkerManager
): AbstractThreadedSyncAdapter(
context,
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
@@ -62,23 +71,38 @@ abstract class SyncAdapterService: Service() {
) {
/**
* Completable [Boolean], which will be set to
*
* - `true` when the related sync worker has finished
* - `false` when the sync framework has requested cancellation.
*
* In any case, the sync framework shouldn't be blocked anymore as soon as a
* value is available.
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
* requests cancellation.
*/
private val finished = CompletableDeferred<Boolean>()
private val waitScope = CoroutineScope(Dispatchers.Default)
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
// We seem to have to pass this old SyncFramework extra for an Android 7 workaround
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
// We have to pass this old SyncFramework extra for an Android 7 workaround
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
logger.info("Sync request via sync framework for $account $authority (upload=$upload)")
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
// If we should sync an address book account - find the account storing the settings
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
AccountManager.get(context)
.getUserData(accountOrAddressBookAccount, USER_DATA_COLLECTION_ID)
?.toLongOrNull()
?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
}
else
accountOrAddressBookAccount
if (account == null) {
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
return
}
val accountSettings = try {
accountSettingsFactory.forAccount(account)
accountSettingsFactory.create(account)
} catch (e: InvalidAccountException) {
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
return
@@ -94,37 +118,40 @@ abstract class SyncAdapterService: Service() {
/* Special case for contacts: because address books are separate accounts, changed contacts cause
this method to be called with authority = ContactsContract.AUTHORITY. However the sync worker shall be run for the
address book authority instead. */
val workerAccount = accountSettings.account // main account in case of an address book account
val workerAuthority =
if (authority == ContactsContract.AUTHORITY)
context.getString(R.string.address_books_authority)
else
authority
logger.fine("Starting OneTimeSyncWorker for $workerAccount $workerAuthority and waiting for it")
val workerName = OneTimeSyncWorker.enqueue(context, workerAccount, workerAuthority, upload = upload)
logger.fine("Starting OneTimeSyncWorker for $account $workerAuthority and waiting for it")
val workerName = syncWorkerManager.enqueueOneTime(account, authority = workerAuthority, upload = upload)
// Because we are not allowed to observe worker state on a background thread, we can not
// use it to block the sync adapter. Instead we check periodically whether the sync has
// finished, putting the thread to sleep in between checks.
/* Because we are not allowed to observe worker state on a background thread, we can not
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
has finished. */
val workManager = WorkManager.getInstance(context)
try {
runBlocking {
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
// wait for finished worker state
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
if (info.any { it.state.isFinished })
cancel(CancellationException("$workerName has finished"))
}
val waitJob = waitScope.launch {
// wait for finished worker state
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
if (info.any { it.state.isFinished })
cancel("$workerName has finished")
}
}
} catch (e: CancellationException) {
runBlocking {
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
waitJob.join() // wait until worker has finished
}
}
} catch (_: CancellationException) {
// waiting for work was cancelled, either by timeout or because the worker has finished
logger.log(Level.FINE, "Not waiting for OneTimeSyncWorker anymore (this is not an error)", e)
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
}
logger.fine("Returning to sync framework")
logger.info("Returning to sync framework.")
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
@@ -135,7 +162,7 @@ abstract class SyncAdapterService: Service() {
logger.info("Sync adapter requested cancellation won't cancel sync, but also won't block sync framework anymore")
// unblock sync framework
finished.complete(false)
waitScope.cancel()
}
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()

View File

@@ -61,7 +61,7 @@ class SyncConditions @AssistedInject constructor(
}
val wifi = context.getSystemService<WifiManager>()!!
val info = wifi.connectionInfo
@Suppress("DEPRECATION") val info = wifi.connectionInfo
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
logger.info("Connected to wrong WiFi network (${info.ssid}), aborting sync")
return false
@@ -89,6 +89,7 @@ class SyncConditions @AssistedInject constructor(
*/
internal fun internetAvailable(): Boolean {
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
@Suppress("DEPRECATION")
return connectivityManager.allNetworks.any { network ->
val capabilities = connectivityManager.getNetworkCapabilities(network)
logger.log(
@@ -127,6 +128,7 @@ class SyncConditions @AssistedInject constructor(
*/
internal fun wifiAvailable(): Boolean {
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
@Suppress("DEPRECATION")
connectivityManager.allNetworks.forEach { network ->
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&

View File

@@ -9,7 +9,6 @@ import android.app.PendingIntent
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.net.Uri
import android.os.DeadObjectException
import android.os.RemoteException
@@ -40,8 +39,10 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalEvent
@@ -155,20 +156,24 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
@Inject
lateinit var notificationRegistry: NotificationRegistry
@Inject
lateinit var accountRepository: AccountRepository
@Inject
lateinit var syncStatsRepository: DavSyncStatsRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var collectionRepository: DavCollectionRepository
init {
// required for ServiceLoader -> ical4j -> ical4android
Ical4Android.checkThreadContextClassLoader()
}
protected val mainAccount = if (localCollection is LocalAddressBook)
localCollection.requireMainAccount()
else
account
protected val notificationTag = localCollection.tag
protected lateinit var davCollection: RemoteType
@@ -327,7 +332,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
logger.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
// determine when to retry
syncResult.delayUntil = getDelayUntil(e.retryAfter).epochSecond
syncResult.stats.numIoExceptions++ // Indicate a soft error occurred
syncResult.stats.numServiceUnavailableExceptions++ // Indicate a soft error occurred
}
// all others
@@ -782,19 +787,19 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
is HttpException, is DavException -> {
logger.log(Level.SEVERE, "HTTP/DAV exception", e)
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
syncResult.stats.numParseExceptions++ // numIoExceptions would indicate a soft error
syncResult.stats.numHttpExceptions++
}
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
logger.log(Level.SEVERE, "Couldn't access local storage", e)
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
syncResult.databaseError = true
syncResult.localStorageError = true
}
else -> {
logger.log(Level.SEVERE, "Unclassified sync error", e)
message = e.localizedMessage ?: e::class.java.simpleName
syncResult.stats.numParseExceptions++
syncResult.stats.numUnclassifiedErrors++
}
}
@@ -804,10 +809,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
contentIntent = Intent(context, AccountSettingsActivity::class.java)
contentIntent.putExtra(
AccountSettingsActivity.EXTRA_ACCOUNT,
if (authority == ContactsContract.AUTHORITY)
mainAccount
else
account
account
)
} else {
contentIntent = buildDebugInfoIntent(e, local, remote)
@@ -833,7 +835,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
.setContentTitle(localCollection.title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
.setSubText(mainAccount.name)
.setSubText(account.name)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
@@ -896,7 +898,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
builder.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(notifyInvalidResourceTitle())
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
.setSubText(mainAccount.name)
.setSubText(account.name)
.setContentIntent(
PendingIntent.getActivity(
context,

View File

@@ -0,0 +1,62 @@
package at.bitfire.davdroid.sync
/**
* This class represents the results of a sync operation from [Syncer].
*
* Used by [at.bitfire.davdroid.sync.worker.BaseSyncWorker] to determine whether or not there will be retries etc.
*/
data class SyncResult(
var contentProviderError: Boolean = false,
var localStorageError: Boolean = false,
var delayUntil: Long = 0,
val stats: SyncStats = SyncStats()
) {
/**
* Whether a hard error occurred.
*/
fun hasHardError(): Boolean =
contentProviderError
|| localStorageError
|| stats.numAuthExceptions > 0
|| stats.numHttpExceptions > 0
|| stats.numUnclassifiedErrors > 0
/**
* Whether a soft error occurred.
*/
fun hasSoftError(): Boolean =
stats.numDeadObjectExceptions > 0
|| stats.numIoExceptions > 0
|| stats.numServiceUnavailableExceptions > 0
/**
* Whether a hard or a soft error occurred.
*/
fun hasError(): Boolean =
hasHardError() || hasSoftError()
/**
* Holds statistics about the sync operation. Used to determine retries. Also useful for
* debugging and customer support when logged.
*/
data class SyncStats(
// Stats
var numDeletes: Long = 0,
var numEntries: Long = 0,
var numInserts: Long = 0,
var numSkippedEntries: Long = 0,
var numUpdates: Long = 0,
// Hard errors
var numAuthExceptions: Long = 0,
var numHttpExceptions: Long = 0,
var numUnclassifiedErrors: Long = 0,
// Soft errors
var numDeadObjectExceptions: Long = 0,
var numIoExceptions: Long = 0,
var numServiceUnavailableExceptions: Long = 0
)
}

View File

@@ -33,7 +33,7 @@ object SyncUtils {
* Checking the availability of authorities may be relatively expensive, so the
* result should be cached for the current operation.
*
* @return list of available sync authorities for main accounts
* @return list of available sync authorities for DAVx5 accounts
*/
fun syncAuthorities(context: Context): List<String> {
val result = mutableListOf(

View File

@@ -7,8 +7,8 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.DeadObjectException
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.network.HttpClient
@@ -77,7 +77,7 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
abstract val authority: String
abstract val serviceType: String
val accountSettings by lazy { accountSettingsFactory.forAccount(account) }
val accountSettings by lazy { accountSettingsFactory.create(account) }
val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() }
/**
@@ -85,71 +85,156 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
* remote collection information. Then syncs the actual entries (events, tasks, contacts, etc)
* of the remaining, now up-to-date, collections.
*/
private fun sync(provider: ContentProviderClient) {
@VisibleForTesting
internal fun sync(provider: ContentProviderClient) {
// Collection type specific preparations
if (!prepare(provider)) {
logger.log(Level.WARNING, "Failed to prepare sync. Won't run sync.")
return
}
// Find sync-enabled collections
// Find collections in database and provider which should be synced (are sync-enabled)
val dbCollections = getSyncEnabledCollections()
val localCollections = getLocalCollections(provider)
// Create/update/delete local collections according to DB
val updatedLocalCollections = updateCollections(provider, localCollections, dbCollections)
// Sync local collection contents (events, contacts, tasks)
syncCollectionContents(provider, updatedLocalCollections, dbCollections)
}
/**
* Finds sync enabled collections in database. They contain collection info which might have
* been updated by collection refresh [at.bitfire.davdroid.servicedetection.DavResourceFinder].
*
* @return The sync enabled collections as hash map identified by their URL
*/
@VisibleForTesting
internal fun getSyncEnabledCollections(): Map<HttpUrl, Collection> {
val dbCollections = mutableMapOf<HttpUrl, Collection>()
serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service ->
for (dbCollection in getSyncCollections(service.id))
for (dbCollection in getDbSyncCollections(service.id))
dbCollections[dbCollection.url] = dbCollection
}
return dbCollections
}
// Update/delete local collections and determine new (unknown) remote collections
val localSyncCollections = localSyncCollections(provider)
val newDbCollections = HashMap(dbCollections) // create a copy
for (localCollection in localSyncCollections) {
val dbCollection = dbCollections[localCollection.url?.toHttpUrlOrNull()]
if (dbCollection == null)
// Collection not available in db = on server (anymore), delete obsolete local collection
localCollection.delete()
else {
/**
* Updates and deletes local collections.
*
* - Updates local collections with possibly new info from corresponding database collections.
* - Deletes local collections without a corresponding database collection.
* - Creates local collections for database collections without local match.
*
* @param provider Content provider client, used to create local collections
* @param localCollections The current local collections
* @param dbCollections The current database collections, possibly containing new information
*
* @return Updated list of local collections (obsolete collections removed, new collections added)
*/
@VisibleForTesting
internal fun updateCollections(
provider: ContentProviderClient,
localCollections: List<CollectionType>,
dbCollections: Map<HttpUrl, Collection>
): List<CollectionType> {
// create mutable copies of input
val updatedLocalCollections = localCollections.toMutableList()
val newDbCollections = dbCollections.toMutableMap()
for (localCollection in localCollections) {
val dbCollection = dbCollections[localCollection.collectionUrl?.toHttpUrlOrNull()]
if (dbCollection == null) {
// Collection not available in db = on server (anymore), delete and remove from the updated list
logger.fine("Deleting local collection ${localCollection.title}")
localCollection.deleteCollection()
updatedLocalCollections -= localCollection
} else {
// Collection exists locally, update local collection and remove it from "to be created" map
logger.fine("Updating local collection ${localCollection.title} with $dbCollection")
update(localCollection, dbCollection)
newDbCollections -= dbCollection.url
}
}
// 3. create new local collections for newly found remote collections
for ((_, collection) in newDbCollections)
create(provider, collection)
// Create local collections which are in DB, but don't exist locally yet
if (newDbCollections.isNotEmpty()) {
val toBeCreated = newDbCollections.values.toList()
logger.log(Level.FINE, "Creating new local collections", toBeCreated.toTypedArray())
val newLocalCollections = createLocalCollections(provider, toBeCreated)
// Add the newly created collections to the updated list
updatedLocalCollections.addAll(newLocalCollections)
}
// 4. sync local collection contents (events, contacts, tasks)
for (localCollection in localSyncCollections)
dbCollections[localCollection.url?.toHttpUrl()]?.let { dbCollection ->
syncCollection(provider, localCollection, dbCollection)
}
return updatedLocalCollections
}
/**
* Creates new local collections from database collections.
*
* @param provider Content provider client to access local collections
* @param dbCollections Database collections to be created as local collections
*
* @return Newly created local collections
*/
@VisibleForTesting
internal fun createLocalCollections(
provider: ContentProviderClient,
dbCollections: List<Collection>
): List<CollectionType> =
dbCollections.map { collection -> create(provider, collection) }
/**
* Synchronize the actual collection contents.
*
* @param provider Content provider client to access local collections
* @param localCollections Collections to be synchronized
* @param dbCollections Remote collection information
*/
@VisibleForTesting
internal fun syncCollectionContents(
provider: ContentProviderClient,
localCollections: List<CollectionType>,
dbCollections: Map<HttpUrl, Collection>
) = localCollections.forEach { localCollection ->
dbCollections[localCollection.collectionUrl?.toHttpUrl()]?.let { dbCollection ->
syncCollection(provider, localCollection, dbCollection)
}
}
/**
* For collection specific sync preparations.
*
* @param provider Content provider for syncer specific authority
* @return *true* to run the sync; *false* to abort
*/
open fun prepare(provider: ContentProviderClient): Boolean = true
/**
* Get the local collections to be updated after sync
* Gets all local collections (not from the database, but from the content provider).
*
* [Syncer] will remove collections which are returned by this method, but not by
* [getDbSyncCollections], and add collections which are returned by [getDbSyncCollections], but not by this method.
*
* @param provider Content provider to access local collections
* @return Local collections to be updated
*/
abstract fun localSyncCollections(provider: ContentProviderClient): List<CollectionType>
abstract fun getLocalCollections(provider: ContentProviderClient): List<CollectionType>
/**
* Get the local database collections which are sync-enabled (should by synchronized)
* Get the local database collections which are sync-enabled (should by synchronized).
*
* [Syncer] will remove collections which are returned by [getLocalCollections], but not by
* this method, and add collections which are returned by this method, but not by [getLocalCollections].
*
* @param serviceId The CalDAV or CardDAV service (account) to be synchronized
* @return Database collections to be synchronized
*/
abstract fun getSyncCollections(serviceId: Long): List<Collection>
abstract fun getDbSyncCollections(serviceId: Long): List<Collection>
/**
* Update an existing local collection with remote collection information
* Updates an existing local collection (in the content provider) with remote collection information (from the DB).
*
* @param localCollection The local collection to be updated
* @param remoteCollection The new remote collection information
@@ -157,15 +242,15 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
abstract fun update(localCollection: CollectionType, remoteCollection: Collection)
/**
* Create a new local collection from remote collection information
* Creates a new local collection (in the content provider) from remote collection information (from the DB).
*
* @param provider The content provider client to create the local collection
* @param remoteCollection The remote collection to be created locally
*/
abstract fun create(provider: ContentProviderClient, remoteCollection: Collection)
abstract fun create(provider: ContentProviderClient, remoteCollection: Collection): CollectionType
/**
* Synchronise local with remote collection contents
* Synchronizes local with remote collection contents.
*
* @param provider The content provider client to access the local collection to be updated
* @param localCollection The local collection to be synchronized
@@ -175,9 +260,10 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
abstract fun syncCollection(provider: ContentProviderClient, localCollection: CollectionType, remoteCollection: Collection)
/**
* Prepares the sync
* - acquires content provider
* - handles occurring sync errors
* Prepares the sync:
*
* - acquire content provider
* - handle occurring sync errors
*/
operator fun invoke() {
logger.log(Level.INFO, "$authority sync of $account initiated", extras.joinToString(", "))
@@ -195,7 +281,7 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
- the content provider is not available at all, for instance because the respective
system app, like "calendar storage" is disabled */
logger.warning("Couldn't connect to content provider of authority $authority")
syncResult.stats.numParseExceptions++ // hard sync error
syncResult.contentProviderError = true
return // Don't continue without provider
}
@@ -210,14 +296,14 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
/* May happen when the remote process dies or (since Android 14) when IPC (for instance with the calendar provider)
is suddenly forbidden because our sync process was demoted from a "service process" to a "cached process". */
logger.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e)
syncResult.stats.numIoExceptions++
syncResult.stats.numDeadObjectExceptions++
} catch (e: InvalidAccountException) {
logger.log(Level.WARNING, "Account was removed during synchronization", e)
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't sync $authority", e)
syncResult.stats.numParseExceptions++ // Hard sync error
syncResult.stats.numUnclassifiedErrors++ // Hard sync error
} finally {
if (httpClient.isInitialized())

View File

@@ -7,7 +7,7 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.SyncResult
import android.content.ContentUris
import android.os.Build
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
@@ -17,7 +17,7 @@ import at.bitfire.ical4android.TaskProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.TaskLists
import java.util.logging.Level
/**
@@ -37,23 +37,21 @@ class TaskSyncer @AssistedInject constructor(
fun create(account: Account, authority: String, extras: Array<String>, syncResult: SyncResult): TaskSyncer
}
private lateinit var taskProvider: TaskProvider
private val providerName = TaskProvider.ProviderName.fromAuthority(authority)
override val serviceType: String
get() = Service.TYPE_CALDAV
override fun localSyncCollections(provider: ContentProviderClient): List<LocalTaskList>
= DmfsTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTaskList>
= DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, "${TaskLists.SYNC_ENABLED}!=0", null)
override fun prepare(provider: ContentProviderClient): Boolean {
// Acquire task provider
val providerName = TaskProvider.ProviderName.fromAuthority(authority)
taskProvider = try {
TaskProvider.fromProviderClient(context, providerName, provider)
// Don't sync if task provider is too old
try {
TaskProvider.checkVersion(context, providerName)
} catch (e: TaskProvider.ProviderTooOldException) {
tasksAppManager.get().notifyProviderTooOld(e)
syncResult.databaseError = true
syncResult.contentProviderError = true
return false // Don't sync
}
@@ -69,7 +67,7 @@ class TaskSyncer @AssistedInject constructor(
return true
}
override fun getSyncCollections(serviceId: Long): List<Collection> =
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
collectionRepository.getSyncTaskLists(serviceId)
override fun update(localCollection: LocalTaskList, remoteCollection: Collection) {
@@ -77,9 +75,10 @@ class TaskSyncer @AssistedInject constructor(
localCollection.update(remoteCollection, accountSettings.getManageCalendarColors())
}
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTaskList {
logger.log(Level.INFO, "Adding local task list", remoteCollection)
LocalTaskList.create(account, taskProvider, remoteCollection)
val uri = LocalTaskList.create(account, provider, providerName, remoteCollection)
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
}
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) {

View File

@@ -21,20 +21,20 @@ import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Logger
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.logging.Logger
import javax.inject.Inject
/**
* Responsible for setting/getting the currently used tasks app, and for communicating with it.
@@ -46,7 +46,8 @@ class TasksAppManager @Inject constructor(
private val db: AppDatabase,
private val logger: Logger,
private val notificationRegistry: Lazy<NotificationRegistry>,
private val settingsManager: SettingsManager
private val settingsManager: SettingsManager,
private val syncWorkerManager: SyncWorkerManager
) {
/**
@@ -130,7 +131,7 @@ class TasksAppManager @Inject constructor(
private fun setSyncable(context: Context, account: Account, authority: String, syncable: Boolean) {
try {
val settings = accountSettingsFactory.forAccount(account)
val settings = accountSettingsFactory.create(account)
if (syncable) {
logger.info("Enabling $authority sync for $account")
@@ -147,11 +148,11 @@ class TasksAppManager @Inject constructor(
ContentResolver.setIsSyncable(account, authority, 0)
// disable periodic sync worker
PeriodicSyncWorker.disable(context, account, authority)
syncWorkerManager.disablePeriodic(account, authority)
}
} catch (e: InvalidAccountException) {
// account has already been removed, make sure periodic sync is disabled, too
PeriodicSyncWorker.disable(context, account, authority)
syncWorkerManager.disablePeriodic(account, authority)
}
}
@@ -168,7 +169,7 @@ class TasksAppManager @Inject constructor(
val pm = context.packageManager
val tasksAppInfo = pm.getPackageInfo(e.provider.packageName, 0)
val tasksAppLabel = tasksAppInfo.applicationInfo.loadLabel(pm)
val tasksAppLabel = tasksAppInfo.applicationInfo?.loadLabel(pm)
val notify = NotificationCompat.Builder(context, registry.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.SyncResult
import android.text.format.Formatter
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
@@ -123,18 +122,21 @@ class TasksSyncManager @AssistedInject constructor(
// multiple iCalendars, use calendar-multi-get
SyncException.wrapWithRemoteResource(collection.url) {
davCollection.multiget(bunch) { response, _ ->
// See CalendarSyncManager for more information about the multi-get response
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
if (!response.isSuccess()) {
logger.warning("Received non-successful multiget response for ${response.href}")
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
return@wrapResource
}
val iCal = response[CalendarData::class.java]?.iCalendar
if (iCal == null) {
logger.warning("Ignoring multi-get response without calendar-data")
return@wrapResource
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
?: throw DavException("Received multi-get response without ETag")
processVTodo(response.href.lastSegment, eTag, StringReader(iCal))
}

View File

@@ -15,7 +15,7 @@ import at.bitfire.davdroid.ui.setup.LoginActivity
/**
* Account authenticator for the main DAVx5 account type.
* Account authenticator for the DAVx5 account type.
*/
class AccountAuthenticatorService: Service() {

View File

@@ -5,9 +5,12 @@
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.hilt.work.HiltWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
@@ -15,8 +18,10 @@ import androidx.work.WorkerParameters
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.time.Duration
import java.util.concurrent.Semaphore
@@ -25,14 +30,73 @@ import java.util.logging.Logger
@HiltWorker
class AccountsCleanupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted val context: Context,
@Assisted workerParameters: WorkerParameters,
private val accountRepository: AccountRepository,
private val collectionRepository: DavCollectionRepository,
private val db: AppDatabase,
private val logger: Logger
): Worker(appContext, workerParameters) {
): Worker(context, workerParameters) {
@AssistedFactory
@VisibleForTesting
interface Factory {
fun create(appContext: Context, workerParams: WorkerParameters): AccountsCleanupWorker
}
private val accountManager = AccountManager.get(context)
override fun doWork(): Result {
lockAccountsCleanup()
try {
cleanupAccounts()
} finally {
unlockAccountsCleanup()
}
return Result.success()
}
private fun cleanupAccounts() {
// Later, accounts which are not in the DB should be deleted here
// Delete orphaned services in DB only necessary as long as accounts are implemented as system accounts (not in DB)
val accounts = accountRepository.getAll()
logger.log(Level.INFO, "Cleaning up accounts. Currently existing accounts:", accounts)
val serviceDao = db.serviceDao()
if (accounts.isEmpty())
serviceDao.deleteAll()
else
serviceDao.deleteExceptAccounts(accounts.map { it.name }.toTypedArray())
// Delete orphaned address book accounts (where db collection is missing)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
deleteOrphanedAddressBookAccounts(accountManager.getAccountsByType(addressBookAccountType))
}
/**
* Deletes address book accounts if they do not have a corresponding collection
*
* @param addressBookAccounts Address book accounts to check
*/
@VisibleForTesting
internal fun deleteOrphanedAddressBookAccounts(addressBookAccounts: Array<Account>) {
addressBookAccounts.forEach { addressBookAccount ->
val collection = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)
?.toLongOrNull()
?.let { collectionId ->
collectionRepository.get(collectionId)
}
if (collection == null) {
// If no collection for this address book exists, we can delete it
logger.info("Deleting address book account without collection: $addressBookAccount")
accountManager.removeAccountExplicitly(addressBookAccount)
}
}
}
companion object {
const val NAME = "accounts-cleanup"
private val mutex = Semaphore(1)
@@ -45,9 +109,18 @@ class AccountsCleanupWorker @AssistedInject constructor(
fun unlockAccountsCleanup() = mutex.release()
/**
* Enqueues [AccountsCleanupWorker] to be run regularly (but not necessarily now).
* Enqueues [AccountsCleanupWorker] to be run once as soon as possible.
*/
fun enqueue(context: Context) {
// run once
val rq = OneTimeWorkRequestBuilder<AccountsCleanupWorker>()
WorkManager.getInstance(context).enqueue(rq.build())
}
/**
* Enqueues [AccountsCleanupWorker] to be run regularly (but not necessarily now).
*/
fun enable(context: Context) {
// run every day
val rq = PeriodicWorkRequestBuilder<AccountsCleanupWorker>(Duration.ofDays(1))
WorkManager.getInstance(context).enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, rq.build())
@@ -55,47 +128,4 @@ class AccountsCleanupWorker @AssistedInject constructor(
}
override fun doWork(): Result {
lockAccountsCleanup()
try {
cleanupAccounts(accountRepository.getAll())
} finally {
unlockAccountsCleanup()
}
return Result.success()
}
private fun cleanupAccounts(accounts: Array<out Account>) {
logger.log(Level.INFO, "Cleaning up accounts. Current accounts in DB:", accounts)
// Later, accounts which are not in the DB should be deleted here
val mainAccountType = applicationContext.getString(R.string.account_type)
val mainAccountNames = accounts
.filter { account -> account.type == mainAccountType }
.map { it.name }
val addressBookAccountType = applicationContext.getString(R.string.account_type_address_book)
val addressBooks = accounts
.filter { account -> account.type == addressBookAccountType }
.map { addressBookAccount -> LocalAddressBook(applicationContext, addressBookAccount, null) }
for (addressBook in addressBooks) {
try {
val mainAccount = addressBook.mainAccount
if (mainAccount == null || !mainAccountNames.contains(mainAccount.name))
// the main account for this address book doesn't exist anymore
addressBook.delete()
} catch(e: Exception) {
logger.log(Level.SEVERE, "Couldn't delete address book account", e)
}
}
// delete orphaned services in DB
val serviceDao = db.serviceDao()
if (mainAccountNames.isEmpty())
serviceDao.deleteAll()
else
serviceDao.deleteExceptAccounts(mainAccountNames.toTypedArray())
}
}

View File

@@ -10,10 +10,10 @@ import android.content.Context
import android.os.Bundle
import at.bitfire.davdroid.util.setAndVerifyUserData
object AccountUtils {
object SystemAccountUtils {
/**
* Creates an account and makes sure the user data are set correctly.
* Creates a system account and makes sure the user data are set correctly.
*
* @param context operating context
* @param account account to create

View File

@@ -7,8 +7,9 @@ package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Build
import android.provider.CalendarContract
import androidx.annotation.IntDef
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
@@ -19,11 +20,13 @@ import androidx.work.WorkQuery
import androidx.work.WorkerParameters
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.push.PushNotificationManager
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.AddressBookSyncer
import at.bitfire.davdroid.sync.CalendarSyncer
import at.bitfire.davdroid.sync.JtxSyncer
import at.bitfire.davdroid.sync.SyncConditions
import at.bitfire.davdroid.sync.SyncResult
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.Syncer
import at.bitfire.davdroid.sync.TaskSyncer
@@ -52,9 +55,22 @@ abstract class BaseSyncWorker(
const val INPUT_ACCOUNT_TYPE = "accountType"
const val INPUT_AUTHORITY = "authority"
/** set to true for user-initiated sync that skips network checks */
/** set to `true` for user-initiated sync that skips network checks */
const val INPUT_MANUAL = "manual"
/** set to `true` for syncs that are caused by local changes */
const val INPUT_UPLOAD = "upload"
/** Whether re-synchronization is requested. One of [NO_RESYNC] (default), [RESYNC] or [FULL_RESYNC]. */
const val INPUT_RESYNC = "resync"
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
annotation class InputResync
const val NO_RESYNC = 0
/** Re-synchronization is requested. See [Syncer.SYNC_EXTRAS_RESYNC] for details. */
const val RESYNC = 1
/** Full re-synchronization is requested. See [Syncer.SYNC_EXTRAS_FULL_RESYNC] for details. */
const val FULL_RESYNC = 2
/**
* How often this work will be retried to run after soft (network) errors.
*
@@ -136,6 +152,9 @@ abstract class BaseSyncWorker(
@Inject
lateinit var notificationRegistry: NotificationRegistry
@Inject
lateinit var pushNotificationManager: PushNotificationManager
@Inject
lateinit var syncConditionsFactory: SyncConditions.Factory
@@ -159,9 +178,12 @@ abstract class BaseSyncWorker(
return Result.success()
}
// Dismiss any pending push notification
pushNotificationManager.dismiss(account, authority)
try {
val accountSettings = try {
accountSettingsFactory.forAccount(account)
accountSettingsFactory.create(account)
} catch (e: InvalidAccountException) {
val workId = workerParams.id
logger.warning("Account $account doesn't exist anymore, cancelling worker $workId")
@@ -194,6 +216,9 @@ abstract class BaseSyncWorker(
} finally {
logger.info("${javaClass.simpleName} finished for $syncTag")
runningSyncs -= syncTag
if (Build.VERSION.SDK_INT >= 31 && stopReason != WorkInfo.STOP_REASON_NOT_STOPPED)
logger.warning("Worker was stopped with reason: $stopReason")
}
}
@@ -206,11 +231,11 @@ abstract class BaseSyncWorker(
// pass possibly supplied flags to the selected syncer
val extrasList = mutableListOf<String>()
when (inputData.getInt(OneTimeSyncWorker.ARG_RESYNC, OneTimeSyncWorker.NO_RESYNC)) {
OneTimeSyncWorker.RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_RESYNC)
OneTimeSyncWorker.FULL_RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
when (inputData.getInt(INPUT_RESYNC, NO_RESYNC)) {
RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_RESYNC)
FULL_RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
}
if (inputData.getBoolean(OneTimeSyncWorker.ARG_UPLOAD, false))
if (inputData.getBoolean(INPUT_UPLOAD, false))
// Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround.
extrasList.add(ContentResolver.SYNC_EXTRAS_UPLOAD)
val extras = extrasList.toTypedArray()

View File

@@ -5,32 +5,17 @@
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import androidx.annotation.IntDef
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.WorkerParameters
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.SyncDispatcher
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
/**
* One-time sync worker.
@@ -48,15 +33,6 @@ class OneTimeSyncWorker @AssistedInject constructor(
companion object {
const val ARG_UPLOAD = "upload"
const val ARG_RESYNC = "resync"
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
annotation class ArgResync
const val NO_RESYNC = 0
const val RESYNC = 1
const val FULL_RESYNC = 2
/**
* Unique work name of this worker. Can also be used as tag.
*
@@ -69,87 +45,6 @@ class OneTimeSyncWorker @AssistedInject constructor(
fun workerName(account: Account, authority: String): String =
"onetime-sync $authority ${account.type}/${account.name}"
/**
* Requests immediate synchronization of an account with all applicable
* authorities (contacts, calendars, …).
*
* @see enqueue
*/
fun enqueueAllAuthorities(
context: Context,
account: Account,
manual: Boolean = false,
@ArgResync resync: Int = NO_RESYNC,
upload: Boolean = false
) {
for (authority in SyncUtils.syncAuthorities(context))
enqueue(context, account, authority, manual = manual, resync = resync, upload = upload)
}
/**
* Requests immediate synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY])
* @param manual user-initiated sync (ignores network checks)
* @param resync whether to request (full) re-synchronization or not
* @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] used only for contacts sync
* and android 7 workaround
* @return existing or newly created worker name
*/
fun enqueue(
context: Context,
account: Account,
authority: String,
manual: Boolean = false,
@ArgResync resync: Int = NO_RESYNC,
upload: Boolean = false
): String {
// Worker arguments
val argumentsBuilder = Data.Builder()
.putString(INPUT_AUTHORITY, authority)
.putString(INPUT_ACCOUNT_NAME, account.name)
.putString(INPUT_ACCOUNT_TYPE, account.type)
if (manual)
argumentsBuilder.putBoolean(INPUT_MANUAL, true)
if (resync != NO_RESYNC)
argumentsBuilder.putInt(ARG_RESYNC, resync)
argumentsBuilder.putBoolean(ARG_UPLOAD, upload)
// build work request
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
.build()
val workRequest = OneTimeWorkRequestBuilder<OneTimeSyncWorker>()
.addTag(workerName(account, authority))
.addTag(commonTag(account, authority))
.setInputData(argumentsBuilder.build())
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, // 30 sec
TimeUnit.MILLISECONDS
)
.setConstraints(constraints)
/* OneTimeSyncWorker is started by user or sync framework when there are local changes.
In both cases, synchronization should be done as soon as possible, so we set expedited. */
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
// enqueue and start syncing
val name = workerName(account, authority)
val request = workRequest.build()
Logger.getGlobal().log(Level.INFO, "Enqueueing unique worker: $name, tags = ${request.tags}")
WorkManager.getInstance(context).enqueueUniqueWork(
name,
/* If sync is already running, just continue.
Existing retried work will not be replaced (for instance when
PeriodicSyncWorker enqueues another scheduled sync). */
ExistingWorkPolicy.KEEP,
request
)
return name
}
}

View File

@@ -6,22 +6,14 @@ package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import androidx.annotation.VisibleForTesting
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.Operation
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.davdroid.sync.SyncDispatcher
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
/**
* Handles scheduled sync requests.
@@ -46,6 +38,12 @@ class PeriodicSyncWorker @AssistedInject constructor(
syncDispatcher: SyncDispatcher
) : BaseSyncWorker(appContext, workerParams, syncDispatcher.dispatcher) {
@AssistedFactory
@VisibleForTesting
interface Factory {
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
}
companion object {
/**
@@ -60,59 +58,6 @@ class PeriodicSyncWorker @AssistedInject constructor(
fun workerName(account: Account, authority: String): String =
"periodic-sync $authority ${account.type}/${account.name}"
/**
* Activate scheduled synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @param interval interval between recurring syncs in seconds
* @return operation object to check when and whether activation was successful
*/
fun enable(context: Context, account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): Operation {
val arguments = Data.Builder()
.putString(INPUT_AUTHORITY, authority)
.putString(INPUT_ACCOUNT_NAME, account.name)
.putString(INPUT_ACCOUNT_TYPE, account.type)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (syncWifiOnly)
NetworkType.UNMETERED
else
NetworkType.CONNECTED
).build()
val workRequest = PeriodicWorkRequestBuilder<PeriodicSyncWorker>(interval, TimeUnit.SECONDS)
.addTag(workerName(account, authority))
.addTag(commonTag(account, authority))
.setInputData(arguments)
.setConstraints(constraints)
.build()
return WorkManager.getInstance(context).enqueueUniquePeriodicWork(
workerName(account, authority),
// if a periodic sync exists already, we want to update it with the new interval
// and/or new required network type (applies on next iteration of periodic worker)
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
/**
* Disables scheduled synchronization of an account for a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @return operation object to check process state of work cancellation
*/
fun disable(context: Context, account: Account, authority: String): Operation =
WorkManager.getInstance(context)
.cancelUniqueWork(workerName(account, authority))
}
@AssistedFactory
@VisibleForTesting
interface Factory {
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
}
}

View File

@@ -0,0 +1,233 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkRequest
import at.bitfire.davdroid.push.PushNotificationManager
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_NAME
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_TYPE
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_AUTHORITY
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_MANUAL
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_RESYNC
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_UPLOAD
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.InputResync
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.NO_RESYNC
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.commonTag
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker.Companion.workerName
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import java.util.logging.Logger
import javax.inject.Inject
/**
* For building and managing synchronization workers (both one-time and periodic).
*
* One-time sync workers can be enqueued. Periodic sync workers can be enabled and disabled.
*/
class SyncWorkerManager @Inject constructor(
@ApplicationContext val context: Context,
val logger: Logger,
val pushNotificationManager: PushNotificationManager
) {
// one-time sync workers
/**
* Builds a one-time sync worker for a specific account and authority.
*
* Arguments: see [enqueueOneTime]
*
* @return one-time sync work request for the given arguments
*/
fun buildOneTime(
account: Account,
authority: String,
manual: Boolean = false,
@InputResync resync: Int = NO_RESYNC,
upload: Boolean = false
): OneTimeWorkRequest {
// worker arguments
val argumentsBuilder = Data.Builder()
.putString(INPUT_AUTHORITY, authority)
.putString(INPUT_ACCOUNT_NAME, account.name)
.putString(INPUT_ACCOUNT_TYPE, account.type)
if (manual)
argumentsBuilder.putBoolean(INPUT_MANUAL, true)
if (resync != NO_RESYNC)
argumentsBuilder.putInt(INPUT_RESYNC, resync)
argumentsBuilder.putBoolean(INPUT_UPLOAD, upload)
// build work request
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
.build()
return OneTimeWorkRequestBuilder<OneTimeSyncWorker>()
.addTag(workerName(account, authority))
.addTag(commonTag(account, authority))
.setInputData(argumentsBuilder.build())
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, // 30 sec
TimeUnit.MILLISECONDS
)
.setConstraints(constraints)
/* OneTimeSyncWorker is started by user or sync framework when there are local changes.
In both cases, synchronization should be done as soon as possible, so we set expedited. */
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
// build work request
.build()
}
/**
* Requests immediate synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY])
* @param manual user-initiated sync (ignores network checks)
* @param resync whether to request (full) re-synchronization or not
* @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] only used for contacts sync and Android 7 workaround
* @param fromPush whether this sync is initiated by a push notification
*
* @return existing or newly created worker name
*/
fun enqueueOneTime(
account: Account,
authority: String,
manual: Boolean = false,
@InputResync resync: Int = NO_RESYNC,
upload: Boolean = false,
fromPush: Boolean = false
): String {
// enqueue and start syncing
val name = workerName(account, authority)
val request = buildOneTime(
account = account,
authority = authority,
manual = manual,
resync = resync,
upload = upload
)
if (fromPush) {
logger.fine("Showing push sync pending notification for $name")
pushNotificationManager.notify(account, authority)
}
logger.info("Enqueueing unique worker: $name, tags = ${request.tags}")
WorkManager.getInstance(context).enqueueUniqueWork(
name,
/* If sync is already running, just continue.
Existing retried work will not be replaced (for instance when
PeriodicSyncWorker enqueues another scheduled sync). */
ExistingWorkPolicy.KEEP,
request
)
return name
}
/**
* Requests immediate synchronization of an account with all applicable
* authorities (contacts, calendars, …).
*
* Arguments: see [enqueueOneTime]
*/
fun enqueueOneTimeAllAuthorities(
account: Account,
manual: Boolean = false,
@InputResync resync: Int = NO_RESYNC,
upload: Boolean = false,
fromPush: Boolean = false
) {
for (authority in SyncUtils.syncAuthorities(context))
enqueueOneTime(
account = account,
authority = authority,
manual = manual,
resync = resync,
upload = upload,
fromPush = fromPush
)
}
// periodic sync workers
/**
* Builds a periodic sync worker for a specific account and authority.
*
* Arguments: see [enablePeriodic]
*
* @return periodic sync work request for the given arguments
*/
fun buildPeriodic(account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): PeriodicWorkRequest {
val arguments = Data.Builder()
.putString(INPUT_AUTHORITY, authority)
.putString(INPUT_ACCOUNT_NAME, account.name)
.putString(INPUT_ACCOUNT_TYPE, account.type)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (syncWifiOnly)
NetworkType.UNMETERED
else
NetworkType.CONNECTED
).build()
return PeriodicWorkRequestBuilder<PeriodicSyncWorker>(interval, TimeUnit.SECONDS)
.addTag(PeriodicSyncWorker.workerName(account, authority))
.addTag(commonTag(account, authority))
.setInputData(arguments)
.setConstraints(constraints)
.build()
}
/**
* Activate periodic synchronization of an account with a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @param interval interval between recurring syncs in seconds
* @return operation object to check when and whether activation was successful
*/
fun enablePeriodic(account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): Operation {
val workRequest = buildPeriodic(account, authority, interval, syncWifiOnly)
return WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PeriodicSyncWorker.workerName(account, authority),
// if a periodic sync exists already, we want to update it with the new interval
// and/or new required network type (applies on next iteration of periodic worker)
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
/**
* Disables periodic synchronization of an account for a specific authority.
*
* @param account account to sync
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
* @return operation object to check process state of work cancellation
*/
fun disablePeriodic(account: Account, authority: String): Operation =
WorkManager.getInstance(context)
.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
}

View File

@@ -284,14 +284,6 @@ fun AboutApp(licenseInfoProvider: AboutActivity.AppLicenseInfoProvider? = null)
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
val buildTime = LocalDateTime.ofEpochSecond(BuildConfig.buildTime / 1000, 0, ZoneOffset.UTC)
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
Text(
stringResource(R.string.about_build_date, dateFormatter.format(buildTime)),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.about_copyright),

View File

@@ -21,6 +21,7 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.davdroid.ui.account.AccountProgress
import at.bitfire.davdroid.ui.intro.IntroPage
import at.bitfire.davdroid.ui.intro.IntroPageFactory
@@ -48,7 +49,8 @@ class AccountsModel @AssistedInject constructor(
@ApplicationContext val context: Context,
private val db: AppDatabase,
introPageFactory: IntroPageFactory,
private val logger: Logger
private val logger: Logger,
private val syncWorkerManager: SyncWorkerManager
): ViewModel() {
@AssistedFactory
@@ -213,7 +215,7 @@ class AccountsModel @AssistedInject constructor(
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
for (account in accountRepository.getAll())
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true)
}
}

View File

@@ -43,19 +43,19 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
@@ -73,6 +73,7 @@ import at.bitfire.davdroid.ui.composable.ProgressBar
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -140,13 +141,11 @@ fun AccountsScreen(
}
}
val refreshState = rememberPullToRefreshState(
enabled = { showSyncAll }
)
LaunchedEffect(refreshState.isRefreshing) {
if (refreshState.isRefreshing) {
onSyncAll()
refreshState.endRefresh()
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
delay(300)
isRefreshing = false
}
}
@@ -225,11 +224,14 @@ fun AccountsScreen(
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(Modifier.padding(padding)) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true; onSyncAll() },
modifier = Modifier.padding(padding)
) {
Box(
Modifier
.fillMaxSize()
.nestedScroll(refreshState.nestedScrollConnection)
.verticalScroll(rememberScrollState())
) {
// background image
@@ -293,12 +295,6 @@ fun AccountsScreen(
.padding(8.dp)
)
}
// indicate when the user pulls down
PullToRefreshContainer(
modifier = Modifier.align(Alignment.TopCenter),
state = refreshState
)
}
}
}
@@ -316,7 +312,9 @@ fun AccountsScreen_Preview_Empty() {
Text("Menu entries")
}
},
accounts = emptyList()
accounts = emptyList(),
showAddAccount = AccountsModel.FABStyle.WithText,
showSyncAll = false
)
}
@@ -347,12 +345,19 @@ fun AccountList(
) {
Column(modifier) {
if (accounts.isEmpty())
Box(
contentAlignment = Alignment.Center,
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
Text(
text = stringResource(R.string.account_list_welcome),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 32.dp)
)
Text(
text = stringResource(R.string.account_list_empty),
style = MaterialTheme.typography.headlineSmall,

View File

@@ -8,10 +8,14 @@ import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import at.bitfire.davdroid.ui.composable.SafeAndroidUriHandler
@Composable
fun AppTheme(
@@ -32,8 +36,11 @@ fun AppTheme(
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
val uriHandler = SafeAndroidUriHandler(LocalContext.current)
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
}

View File

@@ -24,6 +24,7 @@ import android.provider.CalendarContract
import android.provider.ContactsContract
import android.text.format.DateUtils
import android.text.format.Formatter
import androidx.annotation.WorkerThread
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -127,7 +128,7 @@ class DebugInfoModel @AssistedInject constructor(
// create debug info directory
val debugDir = LogFileHandler.debugDir(context) ?: throw IOException("Couldn't create debug info directory")
viewModelScope.launch(Dispatchers.Main) {
viewModelScope.launch(Dispatchers.Default) {
// create log file from EXTRA_LOGS or log file
if (details.logs != null) {
val file = File(debugDir, FILE_LOGS)
@@ -148,7 +149,6 @@ class DebugInfoModel @AssistedInject constructor(
localResource = details.localResource,
remoteResource = details.remoteResource
)
generateDebugInfo(
syncAccount = details.account,
syncAuthority = details.authority,
@@ -164,6 +164,7 @@ class DebugInfoModel @AssistedInject constructor(
*
* Note: Part of this method and all of it's helpers (listed below) should probably be extracted in the future
*/
@WorkerThread
private fun generateDebugInfo(syncAccount: Account?, syncAuthority: String?, cause: Throwable?, localResource: String?, remoteResource: String?) {
val debugInfoFile = File(LogFileHandler.debugDir(context), FILE_DEBUG_INFO)
debugInfoFile.printWriter().use { writer ->
@@ -239,9 +240,9 @@ class DebugInfoModel @AssistedInject constructor(
val info = pm.getPackageInfo(packageName, 0)
val appInfo = info.applicationInfo
val notes = mutableListOf<String>()
if (!appInfo.enabled)
if (appInfo?.enabled == false)
notes += "disabled"
if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
if (appInfo?.flags?.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
notes += "<em>on external storage</em>"
table.addLine(
info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info),
@@ -257,7 +258,7 @@ class DebugInfoModel @AssistedInject constructor(
// system info
val locales: Any = LocaleList.getAdjustedDefault()
writer.append(
"\nSYSTEM INFORMATION\n\n" +
"\n\nSYSTEM INFORMATION\n\n" +
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
"Locale(s): $locales\n" +
@@ -305,7 +306,7 @@ class DebugInfoModel @AssistedInject constructor(
// connectivity
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
writer.append("\nCONNECTIVITY\n\n")
writer.append("\n\nCONNECTIVITY\n\n")
val activeNetwork = connectivityManager.activeNetwork
connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network ->
val properties = connectivityManager.getLinkProperties(network)
@@ -340,7 +341,7 @@ class DebugInfoModel @AssistedInject constructor(
writer.append('\n')
}
writer.append("\nCONFIGURATION\n\n")
writer.append("\n\nCONFIGURATION\n")
// notifications
val nm = NotificationManagerCompat.from(context)
writer.append("\nNotifications")
@@ -366,7 +367,7 @@ class DebugInfoModel @AssistedInject constructor(
// permissions
writer.append("Permissions:\n")
val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
for (permission in ownPkgInfo.requestedPermissions) {
for (permission in ownPkgInfo.requestedPermissions.orEmpty()) {
val shortPermission = permission.removePrefix("android.permission.")
writer.append(" - $shortPermission: ")
.append(
@@ -379,34 +380,21 @@ class DebugInfoModel @AssistedInject constructor(
}
writer.append('\n')
// accounts (grouped by main account)
writer.append("\nACCOUNTS\n\n")
// accounts
writer.append("\nACCOUNTS")
val accountManager = AccountManager.get(context)
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
for (account in accountRepository.getAll()) {
dumpMainAccount(account, writer)
for (account in accountRepository.getAll())
dumpAccount(account, writer)
val iter = addressBookAccounts.iterator()
while (iter.hasNext()) {
val addressBookAccount = iter.next()
val mainAccount = Account(
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME),
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE)
)
if (mainAccount == account) {
dumpAddressBookAccount(addressBookAccount, accountManager, writer)
iter.remove()
}
}
}
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
if (addressBookAccounts.isNotEmpty()) {
writer.append("Address book accounts without main account:\n")
writer.append("ADDRESS BOOK ACCOUNTS\n\n")
for (account in addressBookAccounts)
dumpAddressBookAccount(account, accountManager, writer)
}
// database dump
writer.append("\nDATABASE DUMP\n\n")
writer.append("\n\nDATABASE DUMP\n\n")
db.dump(writer, arrayOf("webdav_document"))
// app settings
@@ -473,12 +461,13 @@ class DebugInfoModel @AssistedInject constructor(
*
* Note: Helper method of [generateDebugInfo].
*/
private fun dumpMainAccount(account: Account, writer: Writer) {
@WorkerThread
private fun dumpAccount(account: Account, writer: Writer) {
writer.append("\n\n - Account: ${account.name}\n")
writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context, account)))
try {
val accountSettings = accountSettingsFactory.forAccount(account)
val accountSettings = accountSettingsFactory.create(account)
writer.append(dumpAccount(account, accountSettings, AccountDumpInfo.caldavAccount(context, account)))
try {
val credentials = accountSettings.credentials()
val authStr = mutableListOf<String>()
if (credentials.username != null)
@@ -501,10 +490,10 @@ class DebugInfoModel @AssistedInject constructor(
}
writer.append(
"\n Contact group method: ${accountSettings.getGroupMethod()}\n" +
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
" Use event colors: ${accountSettings.getEventColors()}\n"
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
" Use event colors: ${accountSettings.getEventColors()}\n"
)
writer.append("\nSync workers:\n")
@@ -523,10 +512,11 @@ class DebugInfoModel @AssistedInject constructor(
*/
private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) {
writer.append(" * Address book: ${account.name}\n")
val table = dumpAccount(account, AccountDumpInfo.addressBookAccount(account))
val table = dumpAccount(account, null, AccountDumpInfo.addressBookAccount(account))
writer.append(TextTable.indent(table, 4))
.append("URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n")
.append("Collection ID: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)}\n")
.append(" URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n\n")
}
/**
@@ -536,7 +526,7 @@ class DebugInfoModel @AssistedInject constructor(
*
* @return the requested information
*/
private fun dumpAccount(account: Account, infos: Iterable<AccountDumpInfo>): String {
private fun dumpAccount(account: Account, accountSettings: AccountSettings?, infos: Iterable<AccountDumpInfo>): String {
val table = TextTable("Authority", "isSyncable", "syncAutomatically", "Interval", "Entries")
for (info in infos) {
var nrEntries = ""
@@ -550,12 +540,11 @@ class DebugInfoModel @AssistedInject constructor(
} catch (e: Exception) {
nrEntries = e.toString()
}
val accountSettings = accountSettingsFactory.forAccount(account)
table.addLine(
info.authority,
ContentResolver.getIsSyncable(account, info.authority),
ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync
accountSettings.getSyncInterval(info.authority)?.takeIf { it >= 0 }?.let {"${it/60} min"},
accountSettings?.getSyncInterval(info.authority)?.takeIf { it >= 0 }?.let {"${it/60} min"},
nrEntries
)
}
@@ -612,7 +601,7 @@ data class AccountDumpInfo(
companion object {
fun mainAccount(context: Context, account: Account) = listOf(
fun caldavAccount(context: Context, account: Account) = listOf(
AccountDumpInfo(account, context.getString(R.string.address_books_authority), null, null),
AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"),
AccountDumpInfo(account, TaskProvider.ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"),

View File

@@ -151,7 +151,6 @@ class NotificationRegistry @Inject constructor(
logger.warning("Notifications disabled, not showing notification $id")
}
// specific common notifications
/**

View File

@@ -11,12 +11,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -35,21 +41,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.BasicTopAppBar
import at.bitfire.davdroid.ui.composable.CardWithImage
import at.bitfire.davdroid.ui.composable.RadioWithSwitch
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksScreen(onNavUp: () -> Unit) {
AppTheme {
Scaffold(
topBar = {
BasicTopAppBar(
titleStringRes = R.string.intro_tasks_title,
onNavigateUp = onNavUp
TopAppBar(
title = { Text(stringResource(R.string.intro_tasks_title)) },
navigationIcon = {
IconButton(
onClick = onNavUp
) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
}
}
)
}
) { paddingValues ->
@@ -159,7 +170,7 @@ fun TasksCard(
stringResource(R.string.intro_tasks_tasks_org_info),
HtmlCompat.FROM_HTML_MODE_COMPACT
).toAnnotatedString()
ClickableTextWithLink(summary)
Text(summary)
},
isSelected = tasksOrgSelected,
isToggled = tasksOrgInstalled,

View File

@@ -25,9 +25,8 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.UrlAnnotation
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -51,14 +50,12 @@ object UiUtils {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UiUtilsEntryPoint {
fun logger(): Logger
fun settingsManager(): SettingsManager
}
const val SHORTCUT_SYNC_ALL = "syncAllAccounts"
private val logger: Logger
get() = Logger.getGlobal()
@Composable
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
@@ -93,13 +90,12 @@ object UiUtils {
.build()
)
} catch(e: Exception) {
val logger = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).logger()
logger.log(Level.WARNING, "Couldn't update dynamic shortcut(s)", e)
}
}
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun Spanned.toAnnotatedString() = buildAnnotatedString {
val spanned = this@toAnnotatedString
@@ -121,8 +117,8 @@ object UiUtils {
)
}
is URLSpan -> {
addUrlAnnotation(
UrlAnnotation(span.url),
addLink(
LinkAnnotation.Url(span.url),
start = start, end = end
)
addStyle(
@@ -133,8 +129,11 @@ object UiUtils {
start = start, end = end
)
}
else ->
else -> {
val context = LocalContext.current
val logger = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).logger()
logger.warning("Ignoring unknown span type ${span.javaClass.name}")
}
}
}
}

View File

@@ -4,7 +4,6 @@ import android.accounts.Account
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -29,7 +28,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
@@ -41,8 +40,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -54,12 +52,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -79,6 +77,7 @@ import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.ical4android.TaskProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -201,14 +200,6 @@ fun AccountScreen(
if (invalidAccount)
onFinish()
val pullRefreshState = rememberPullToRefreshState()
LaunchedEffect(pullRefreshState.isRefreshing) {
if (pullRefreshState.isRefreshing) {
onSync()
pullRefreshState.endRefresh()
}
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
if (error != null)
@@ -218,6 +209,14 @@ fun AccountScreen(
}
}
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
delay(300)
isRefreshing = false
}
}
// tabs calculation
var nextIdx = -1
@@ -298,70 +297,71 @@ fun AccountScreen(
SnackbarHost(snackbarHostState)
}
) { padding ->
Box(
Modifier
.padding(padding)
.nestedScroll(pullRefreshState.nestedScrollConnection)
Column(
modifier = Modifier.padding(padding)
) {
Column {
if (nrPages > 0) {
TabRow(selectedTabIndex = pagerState.currentPage) {
if (idxCalDav != null) {
Tab(
selected = pagerState.currentPage == idxCalDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCalDav)
}
if (nrPages > 0) {
TabRow(selectedTabIndex = pagerState.currentPage) {
if (idxCalDav != null) {
Tab(
selected = pagerState.currentPage == idxCalDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCalDav)
}
) {
Text(
stringResource(R.string.account_caldav),
modifier = Modifier.padding(8.dp)
)
}
}
if (idxCardDav != null) {
Tab(
selected = pagerState.currentPage == idxCardDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCardDav)
}
}
) {
Text(
stringResource(R.string.account_carddav),
modifier = Modifier.padding(8.dp)
)
}
}
if (idxWebcal != null) {
Tab(
selected = pagerState.currentPage == idxWebcal,
onClick = {
scope.launch {
pagerState.scrollToPage(idxWebcal)
}
}
) {
Text(
stringResource(R.string.account_webcal),
modifier = Modifier.padding(8.dp)
)
}
) {
Text(
stringResource(R.string.account_caldav),
modifier = Modifier.padding(8.dp)
)
}
}
HorizontalPager(
pagerState,
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { index ->
if (idxCardDav != null) {
Tab(
selected = pagerState.currentPage == idxCardDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCardDav)
}
}
) {
Text(
stringResource(R.string.account_carddav),
modifier = Modifier.padding(8.dp)
)
}
}
if (idxWebcal != null) {
Tab(
selected = pagerState.currentPage == idxWebcal,
onClick = {
scope.launch {
pagerState.scrollToPage(idxWebcal)
}
}
) {
Text(
stringResource(R.string.account_webcal),
modifier = Modifier.padding(8.dp)
)
}
}
}
HorizontalPager(
pagerState,
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { index ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true; onSync() }
) {
when (index) {
idxCardDav ->
AccountScreen_ServiceTab(
@@ -424,18 +424,12 @@ fun AccountScreen(
}
}
}
PullToRefreshContainer(
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AccountScreen_Actions(
accountName: String,
canCreateAddressBook: Boolean,
@@ -512,7 +506,7 @@ fun AccountScreen_Actions(
DropdownMenuItem(
leadingIcon = {
CompositionLocalProvider(
LocalMinimumInteractiveComponentEnforcement provides false
LocalMinimumInteractiveComponentSize provides Dp.Unspecified
) {
Checkbox(
checked = showOnlyPersonal.onlyPersonal,

View File

@@ -24,7 +24,7 @@ import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -47,14 +47,15 @@ class AccountScreenModel @AssistedInject constructor(
@Assisted val account: Account,
private val accountRepository: AccountRepository,
accountProgressUseCase: AccountProgressUseCase,
accountSettingsFactory: AccountSettings.Factory,
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext val context: Context,
getBindableHomesetsFromService: GetBindableHomeSetsFromServiceUseCase,
getServiceCollectionPager: GetServiceCollectionPagerUseCase,
private val logger: Logger,
serviceRepository: DavServiceRepository,
private val tasksAppManager: TasksAppManager
private val syncWorkerManager: SyncWorkerManager,
tasksAppManager: TasksAppManager
): ViewModel() {
@AssistedFactory
@@ -67,18 +68,19 @@ class AccountScreenModel @AssistedInject constructor(
!accounts.contains(account)
}
private val settings = accountSettingsFactory.forAccount(account)
private val refreshSettingsSignal = MutableLiveData(Unit)
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
init {
viewModelScope.launch(Dispatchers.IO) {
val settings = accountSettingsFactory.create(account)
postValue(settings.getShowOnlyPersonal())
}
}
}
}.asFlow()
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) = viewModelScope.launch(Dispatchers.IO) {
val settings = accountSettingsFactory.create(account)
settings.setShowOnlyPersonal(showOnlyPersonal)
refreshSettingsSignal.postValue(Unit)
}
@@ -162,7 +164,7 @@ class AccountScreenModel @AssistedInject constructor(
// synchronize again
val newAccount = Account(context.getString(R.string.account_type), newName)
OneTimeSyncWorker.enqueueAllAuthorities(context, newAccount, manual = true)
syncWorkerManager.enqueueOneTimeAllAuthorities(newAccount, manual = true)
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't rename account", e)
error = e.localizedMessage
@@ -177,7 +179,7 @@ class AccountScreenModel @AssistedInject constructor(
}
fun sync() {
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true)
}
}

View File

@@ -3,18 +3,15 @@ package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.Syncer
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
@@ -24,17 +21,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.logging.Logger
@HiltViewModel(assistedFactory = AccountSettingsModel.Factory::class)
class AccountSettingsModel @AssistedInject constructor(
@Assisted val account: Account,
accountSettingsFactory: AccountSettings.Factory,
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
private val logger: Logger,
private val settings: SettingsManager,
private val tasksAppManager: TasksAppManager
private val syncWorkerManager: SyncWorkerManager,
tasksAppManager: TasksAppManager
): ViewModel(), SettingsManager.OnChangeListener {
@AssistedFactory
@@ -42,32 +44,42 @@ class AccountSettingsModel @AssistedInject constructor(
fun create(account: Account): AccountSettingsModel
}
private val accountSettings = accountSettingsFactory.forAccount(account)
// settings
var syncIntervalContacts by mutableStateOf<Long?>(null)
var syncIntervalCalendars by mutableStateOf<Long?>(null)
data class UiState(
val syncIntervalContacts: Long? = null,
val syncIntervalCalendars: Long? = null,
val syncIntervalTasks: Long? = null,
val syncWifiOnly: Boolean = false,
val syncWifiOnlySSIDs: List<String>? = null,
val ignoreVpns: Boolean = false,
val credentials: Credentials = Credentials(),
val timeRangePastDays: Int? = null,
val defaultAlarmMinBefore: Int? = null,
val manageCalendarColors: Boolean = false,
val eventColors: Boolean = false,
val contactGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
private val tasksProvider = tasksAppManager.currentProvider()
var syncIntervalTasks by mutableStateOf<Long?>(null)
var syncWifiOnly by mutableStateOf(false)
var syncWifiOnlySSIDs by mutableStateOf<List<String>?>(null)
var ignoreVpns by mutableStateOf(false)
var credentials by mutableStateOf(Credentials())
var timeRangePastDays by mutableStateOf<Int?>(null)
var defaultAlarmMinBefore by mutableStateOf<Int?>(null)
var manageCalendarColors by mutableStateOf(false)
var eventColors by mutableStateOf(false)
var contactGroupMethod by mutableStateOf(GroupMethod.GROUP_VCARDS)
/**
* Only acquire account settings on a worker thread!
*/
private val accountSettings by lazy { accountSettingsFactory.create(account) }
init {
settings.addOnChangeListener(this)
reload()
viewModelScope.launch {
reload()
}
}
override fun onCleared() {
@@ -76,32 +88,32 @@ class AccountSettingsModel @AssistedInject constructor(
}
override fun onSettingsChanged() {
reload()
}
private fun reload() {
logger.info("Reloading settings")
Snapshot.withMutableSnapshot {
syncIntervalContacts = accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))
syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
syncIntervalTasks = tasksProvider?.let { accountSettings.getSyncInterval(it.authority) }
syncWifiOnly = accountSettings.getSyncWifiOnly()
syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs()
ignoreVpns = accountSettings.getIgnoreVpns()
credentials = accountSettings.credentials()
timeRangePastDays = accountSettings.getTimeRangePastDays()
defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
manageCalendarColors = accountSettings.getManageCalendarColors()
eventColors = accountSettings.getEventColors()
contactGroupMethod = accountSettings.getGroupMethod()
viewModelScope.launch {
reload()
}
}
private suspend fun reload() = withContext(Dispatchers.Default) {
logger.info("Reloading settings")
_uiState.value = UiState(
syncIntervalContacts = accountSettings.getSyncInterval(context.getString(R.string.address_books_authority)),
syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY),
syncIntervalTasks = tasksProvider?.let { accountSettings.getSyncInterval(it.authority) },
syncWifiOnly = accountSettings.getSyncWifiOnly(),
syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs(),
ignoreVpns = accountSettings.getIgnoreVpns(),
credentials = accountSettings.credentials(),
timeRangePastDays = accountSettings.getTimeRangePastDays(),
defaultAlarmMinBefore = accountSettings.getDefaultAlarm(),
manageCalendarColors = accountSettings.getManageCalendarColors(),
eventColors = accountSettings.getEventColors(),
contactGroupMethod = accountSettings.getGroupMethod(),
)
}
fun updateContactsSyncInterval(syncInterval: Long) {
CoroutineScope(Dispatchers.Default).launch {
@@ -126,27 +138,27 @@ class AccountSettingsModel @AssistedInject constructor(
}
}
fun updateSyncWifiOnly(wifiOnly: Boolean) {
fun updateSyncWifiOnly(wifiOnly: Boolean) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setSyncWiFiOnly(wifiOnly)
reload()
}
fun updateSyncWifiOnlySSIDs(ssids: List<String>?) {
fun updateSyncWifiOnlySSIDs(ssids: List<String>?) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setSyncWifiOnlySSIDs(ssids)
reload()
}
fun updateIgnoreVpns(ignoreVpns: Boolean) {
fun updateIgnoreVpns(ignoreVpns: Boolean) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setIgnoreVpns(ignoreVpns)
reload()
}
fun updateCredentials(credentials: Credentials) {
fun updateCredentials(credentials: Credentials) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.credentials(credentials)
reload()
}
fun updateTimeRangePastDays(days: Int?) {
fun updateTimeRangePastDays(days: Int?) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setTimeRangePastDays(days)
reload()
@@ -158,28 +170,28 @@ class AccountSettingsModel @AssistedInject constructor(
resyncCalendars(fullResync = days == null, tasks = false)
}
fun updateDefaultAlarm(minBefore: Int?) {
fun updateDefaultAlarm(minBefore: Int?) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setDefaultAlarm(minBefore)
reload()
resyncCalendars(fullResync = true, tasks = false)
}
fun updateManageCalendarColors(manage: Boolean) {
fun updateManageCalendarColors(manage: Boolean) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setManageCalendarColors(manage)
reload()
resyncCalendars(fullResync = false, tasks = true)
}
fun updateEventColors(manageColors: Boolean) {
fun updateEventColors(manageColors: Boolean) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setEventColors(manageColors)
reload()
resyncCalendars(fullResync = true, tasks = false)
}
fun updateContactGroupMethod(groupMethod: GroupMethod) {
fun updateContactGroupMethod(groupMethod: GroupMethod) = CoroutineScope(Dispatchers.Default).launch {
accountSettings.setGroupMethod(groupMethod)
reload()
@@ -193,8 +205,8 @@ class AccountSettingsModel @AssistedInject constructor(
* Initiates calendar re-synchronization.
*
* @param fullResync whether sync shall download all events again
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
* (_true_: sets [at.bitfire.davdroid.sync.Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [at.bitfire.davdroid.sync.Syncer.SYNC_EXTRAS_RESYNC])
* @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks)
*/
private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) {
@@ -214,10 +226,10 @@ class AccountSettingsModel @AssistedInject constructor(
private fun resync(authority: String, fullResync: Boolean) {
val resync =
if (fullResync)
OneTimeSyncWorker.FULL_RESYNC
BaseSyncWorker.FULL_RESYNC
else
OneTimeSyncWorker.RESYNC
OneTimeSyncWorker.enqueue(context, account, authority, resync = resync)
BaseSyncWorker.RESYNC
syncWorkerManager.enqueueOneTime(account, authority = authority, resync = resync)
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Wifi
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.outlined.Task
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -30,6 +32,7 @@ import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -70,6 +73,7 @@ fun AccountSettingsScreen(
val model = hiltViewModel { factory: AccountSettingsModel.Factory ->
factory.create(account)
}
val uiState by model.uiState.collectAsState()
val canAccessWifiSsid by PermissionUtils.rememberCanAccessWifiSsid()
AppTheme {
@@ -80,35 +84,35 @@ fun AccountSettingsScreen(
// Sync settings
canAccessWifiSsid = canAccessWifiSsid,
onSyncWifiOnlyPermissionsAction = onNavWifiPermissionsScreen,
contactsSyncInterval = model.syncIntervalContacts,
contactsSyncInterval = uiState.syncIntervalContacts,
onUpdateContactsSyncInterval = model::updateContactsSyncInterval,
calendarSyncInterval = model.syncIntervalCalendars,
calendarSyncInterval = uiState.syncIntervalCalendars,
onUpdateCalendarSyncInterval = model::updateCalendarSyncInterval,
tasksSyncInterval = model.syncIntervalTasks,
tasksSyncInterval = uiState.syncIntervalTasks,
onUpdateTasksSyncInterval = model::updateTasksSyncInterval,
syncOnlyOnWifi = model.syncWifiOnly,
syncOnlyOnWifi = uiState.syncWifiOnly,
onUpdateSyncOnlyOnWifi = model::updateSyncWifiOnly,
onlyOnSsids = model.syncWifiOnlySSIDs,
onlyOnSsids = uiState.syncWifiOnlySSIDs,
onUpdateOnlyOnSsids = model::updateSyncWifiOnlySSIDs,
ignoreVpns = model.ignoreVpns,
ignoreVpns = uiState.ignoreVpns,
onUpdateIgnoreVpns = model::updateIgnoreVpns,
// Authentication Settings
credentials = model.credentials,
credentials = uiState.credentials,
onUpdateCredentials = model::updateCredentials,
// CalDav Settings
timeRangePastDays = model.timeRangePastDays,
timeRangePastDays = uiState.timeRangePastDays,
onUpdateTimeRangePastDays = model::updateTimeRangePastDays,
defaultAlarmMinBefore = model.defaultAlarmMinBefore,
defaultAlarmMinBefore = uiState.defaultAlarmMinBefore,
onUpdateDefaultAlarmMinBefore = model::updateDefaultAlarm,
manageCalendarColors = model.manageCalendarColors,
manageCalendarColors = uiState.manageCalendarColors,
onUpdateManageCalendarColors = model::updateManageCalendarColors,
eventColors = model.eventColors,
eventColors = uiState.eventColors,
onUpdateEventColors = model::updateEventColors,
// CardDav Settings
contactGroupMethod = model.contactGroupMethod,
contactGroupMethod = uiState.contactGroupMethod,
onUpdateContactGroupMethod = model::updateContactGroupMethod,
)
}
@@ -393,13 +397,23 @@ fun SyncSettings(
onDismiss = { showWifiOnlySsidsDialog = false }
)
if (LocalInspectionMode.current || (onlyOnSsids != null && !canAccessWifiSsid))
if (LocalInspectionMode.current || onlyOnSsids != null)
ActionCard(
icon = Icons.Default.SyncProblem,
icon = if (!canAccessWifiSsid) Icons.Default.SyncProblem else Icons.Default.Info,
actionText = stringResource(R.string.settings_sync_wifi_only_ssids_permissions_action),
onAction = onSyncWifiOnlyPermissionsAction
) {
Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required))
Column {
if (!canAccessWifiSsid)
Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required))
Text(
stringResource(
R.string.wifi_permissions_background_location_disclaimer, stringResource(
R.string.app_name)
),
style = MaterialTheme.typography.bodyMedium
)
}
}
SwitchSetting(

View File

@@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.RemoveCircle
import androidx.compose.material.icons.filled.Task
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -112,6 +113,9 @@ fun CollectionList_Item(
modifier = modifier.clickable(onClick = onShowDetails)
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
modifier = modifier
) {
Row(Modifier.height(IntrinsicSize.Max)) {

View File

@@ -17,6 +17,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import java.text.Collator
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -24,11 +28,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import java.text.Collator
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
import java.util.TimeZone
@HiltViewModel(assistedFactory = CreateCalendarModel.Factory::class)
class CreateCalendarModel @AssistedInject constructor(
@@ -75,7 +74,7 @@ class CreateCalendarModel @AssistedInject constructor(
val color: Int = Css3Color.entries.random().argb,
val displayName: String = "",
val description: String = "",
val timeZoneId: String? = TimeZone.getDefault().id,
val timeZoneId: String? = null,
val supportVEVENT: Boolean = true,
val supportVTODO: Boolean = true,
val supportVJOURNAL: Boolean = true,

View File

@@ -6,7 +6,9 @@ import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -116,6 +118,34 @@ fun WifiPermissionsScreenContent(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())) {
// Disclaimer
Row {
Column(
modifier = Modifier.weight(1f)
) {
Text(
stringResource(
R.string.wifi_permissions_background_location_disclaimer, stringResource(
R.string.app_name)
),
style = MaterialTheme.typography.bodyMedium,
)
Text(
stringResource(
R.string.wifi_permissions_background_location_disclaimer2, stringResource(
R.string.app_name)
),
style = MaterialTheme.typography.bodyMedium,
)
}
Icon(Icons.Default.CloudOff, null, modifier = Modifier.padding(8.dp))
}
HorizontalDivider(Modifier.padding(vertical = 16.dp))
// Permission switches
Text(
stringResource(R.string.wifi_permissions_intro),
style = MaterialTheme.typography.bodyLarge
@@ -150,25 +180,11 @@ fun WifiPermissionsScreenContent(
)
val context = LocalContext.current
OutlinedButton(
modifier = Modifier.padding(top = 8.dp),
onClick = { PermissionUtils.showAppSettings(context) }
) {
Text(stringResource(R.string.permissions_app_settings))
}
HorizontalDivider(Modifier.padding(vertical = 16.dp))
// Disclaimer
Row {
Text(
stringResource(
R.string.wifi_permissions_background_location_disclaimer, stringResource(
R.string.app_name)
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Icon(Icons.Default.CloudOff, null, modifier = Modifier.padding(8.dp))
}
}
}

View File

@@ -46,7 +46,7 @@ fun ActionCard(
modifier = Modifier.fillMaxWidth()
) {
Icon(icon, "", Modifier
.align(Alignment.CenterVertically)
.align(Alignment.Top)
.padding(8.dp))
content()
}
@@ -72,6 +72,9 @@ fun ActionCard_Sample() {
icon = Icons.Default.NotificationAdd,
actionText = "Some Action"
) {
Text("Some Content")
Column {
Text("Some Content. Some Content. Some Content. Some Content. ")
Text("Other Content. Other Content. Other Content. Other Content. Other Content. Other Content. Other Content. ", style = MaterialTheme.typography.bodyMedium)
}
}
}

View File

@@ -25,10 +25,17 @@ import androidx.compose.ui.unit.dp
fun Assistant(
nextLabel: String? = null,
nextEnabled: Boolean = true,
isLoading: Boolean = false,
onNext: () -> Unit = {},
content: @Composable () -> Unit
) {
Column(Modifier.fillMaxSize()) {
if (isLoading)
ProgressBar(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp))
Column(
Modifier
.fillMaxWidth()

View File

@@ -1,37 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.composable
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import at.bitfire.davdroid.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Deprecated("Directly use TopAppBar instead.", replaceWith = ReplaceWith("TopAppBar"))
fun BasicTopAppBar(
@StringRes titleStringRes: Int,
actions: @Composable () -> Unit = {},
onNavigateUp: () -> Unit
) {
TopAppBar(
title = { Text(stringResource(titleStringRes)) },
navigationIcon = {
IconButton(
onClick = onNavigateUp
) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
}
}
)
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
@@ -45,6 +46,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.M3ColorScheme
import kotlinx.coroutines.launch
@Composable
@@ -56,48 +58,51 @@ fun IntroScreen(
) {
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { pages[it].ComposePage() }
Box(
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.background(MaterialTheme.colorScheme.primary)
) {
PositionIndicator(
index = pagerState.currentPage,
max = pages.size,
Scaffold { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 128.dp)
.align(Alignment.Center)
.fillMaxWidth(),
selectedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
unselectedIndicatorColor = MaterialTheme.colorScheme.tertiary,
indicatorSize = 15f
)
.fillMaxWidth()
.weight(1f)
) { pages[it].ComposePage() }
ButtonWithIcon(
icon = if (pagerState.currentPage + 1 == pagerState.pageCount) {
Icons.Default.Check
} else {
Icons.AutoMirrored.Default.ArrowForward
},
contentDescription = stringResource(R.string.intro_next),
Box(
modifier = Modifier
.padding(end = 16.dp)
.align(Alignment.CenterEnd)
.fillMaxWidth()
.height(90.dp)
.background(M3ColorScheme.primaryLight)
) {
if (pagerState.currentPage + 1 == pagerState.pageCount) {
onDonePressed()
} else scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
PositionIndicator(
index = pagerState.currentPage,
max = pages.size,
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 128.dp)
.align(Alignment.Center)
.fillMaxWidth(),
selectedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
unselectedIndicatorColor = MaterialTheme.colorScheme.tertiary,
indicatorSize = 15f
)
ButtonWithIcon(
icon = if (pagerState.currentPage + 1 == pagerState.pageCount) {
Icons.Default.Check
} else {
Icons.AutoMirrored.Default.ArrowForward
},
contentDescription = stringResource(R.string.intro_next),
modifier = Modifier
.padding(end = 16.dp)
.align(Alignment.CenterEnd),
color = M3ColorScheme.tertiaryLight
) {
if (pagerState.currentPage + 1 == pagerState.pageCount) {
onDonePressed()
} else scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
@@ -219,11 +224,12 @@ fun ButtonWithIcon(
modifier: Modifier = Modifier,
size: Dp = 56.dp,
color: Color = MaterialTheme.colorScheme.tertiary,
contentColor: Color = contentColorFor(backgroundColor = color),
onClick: () -> Unit
) {
Surface(
color = color,
contentColor = contentColorFor(backgroundColor = color),
contentColor = contentColor,
modifier = modifier
.size(size)
.aspectRatio(1f),

View File

@@ -44,7 +44,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.vcard4android.GroupMethod
@Composable
@@ -93,17 +92,12 @@ fun AccountDetailsPageContent(
creatingAccount: Boolean
) {
Assistant(
nextLabel = stringResource(R.string.login_add_account),
nextLabel = stringResource(R.string.login_finish),
onNext = onCreateAccount,
nextEnabled = !creatingAccount && accountName.isNotBlank() && !accountNameAlreadyExists
nextEnabled = !creatingAccount && accountName.isNotBlank() && !accountNameAlreadyExists,
isLoading = creatingAccount
) {
Column(Modifier.padding(8.dp)) {
if (creatingAccount)
ProgressBar(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp))
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,

View File

@@ -39,7 +39,6 @@ import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
object AdvancedLogin : LoginType {
@@ -134,8 +133,8 @@ fun AdvancedLoginScreen(
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
urlInfo.toAnnotatedString(),
Text(
text = urlInfo.toAnnotatedString(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()

View File

@@ -32,7 +32,6 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
@Composable
fun DetectResourcesPage(
@@ -125,8 +124,12 @@ fun DetectResourcesPageContent_NothingFound(
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.withStatParams("DetectResourcesPage")
.build()
ClickableTextWithLink(
HtmlCompat.fromHtml(stringResource(R.string.login_see_tested_services, urlServices), HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString(),
val testedServices = HtmlCompat.fromHtml(
stringResource(R.string.login_see_tested_services, urlServices),
HtmlCompat.FROM_HTML_MODE_COMPACT
).toAnnotatedString()
Text(
text = testedServices,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp)
)

View File

@@ -36,7 +36,6 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
object EmailLogin : LoginType {
@@ -122,8 +121,8 @@ fun EmailLoginScreen(
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val emailInfo = HtmlCompat.fromHtml(stringResource(R.string.login_email_address_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
emailInfo.toAnnotatedString(),
Text(
text = emailInfo.toAnnotatedString(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()

View File

@@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
@@ -51,7 +50,6 @@ import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.setup.GoogleLogin.GOOGLE_POLICY_URL
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import java.util.logging.Level
import java.util.logging.Logger
@@ -135,7 +133,6 @@ object GoogleLogin : LoginType {
}
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun GoogleLoginScreen(
email: String,
@@ -257,16 +254,16 @@ fun GoogleLoginScreen(
privacyPolicyUrl.toString()
), 0
).toAnnotatedString()
ClickableTextWithLink(
privacyPolicyNote,
Text(
text = privacyPolicyNote,
style = MaterialTheme.typography.bodyMedium
)
val limitedUseNote = HtmlCompat.fromHtml(
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
).toAnnotatedString()
ClickableTextWithLink(
limitedUseNote,
Text(
text = limitedUseNote,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)

View File

@@ -37,7 +37,6 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
object UrlLogin : LoginType {
@@ -126,8 +125,8 @@ fun UrlLoginScreen(
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
urlInfo.toAnnotatedString(),
Text(
text = urlInfo.toAnnotatedString(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()

View File

@@ -12,7 +12,6 @@ import android.text.format.Formatter
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -30,7 +29,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -40,8 +40,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -50,7 +49,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@@ -68,8 +66,8 @@ import at.bitfire.davdroid.db.WebDavMountWithQuota
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import at.bitfire.davdroid.util.DavUtils
import kotlinx.coroutines.delay
import okhttp3.HttpUrl
@Composable
@@ -108,11 +106,11 @@ fun WebdavMountsScreen(
) {
val uriHandler = LocalUriHandler.current
val refreshState = rememberPullToRefreshState()
LaunchedEffect(refreshState.isRefreshing) {
if (refreshState.isRefreshing) {
onRefreshQuota()
refreshState.endRefresh()
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
delay(300)
isRefreshing = false
}
}
@@ -155,10 +153,11 @@ fun WebdavMountsScreen(
contentDescription = stringResource(R.string.webdav_add_mount_add)
)
}
},
modifier = Modifier.nestedScroll(refreshState.nestedScrollConnection)
}
) { paddingValues ->
Box(
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true; onRefreshQuota() },
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
@@ -176,7 +175,7 @@ fun WebdavMountsScreen(
Spacer(Modifier.height(4.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
@@ -190,11 +189,6 @@ fun WebdavMountsScreen(
}
}
}
PullToRefreshContainer(
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
@@ -222,7 +216,7 @@ fun HintText() {
),
0
).toAnnotatedString()
ClickableTextWithLink(
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth()
@@ -260,8 +254,10 @@ fun WebdavMountsItem(
)
}
Card(
modifier = Modifier.fillMaxWidth()
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
) {
Column(
modifier = Modifier
@@ -356,33 +352,59 @@ fun WebdavMountsItem(
@Composable
@Preview
fun WebdavMountsScreen_Preview_Empty() {
WebdavMountsScreen(
mountInfos = emptyList(),
refreshingQuota = false
)
AppTheme {
WebdavMountsScreen(
mountInfos = emptyList(),
refreshingQuota = false
)
}
}
@Composable
@Preview
fun WebdavMountsScreen_Preview_TwoMounts() {
WebdavMountsScreen(
mountInfos = listOf(
WebDavMountWithQuota(
AppTheme {
WebdavMountsScreen(
mountInfos = listOf(
WebDavMountWithQuota(
mount = WebDavMount(
id = 0,
name = "Preview Webdav Mount 1",
url = HttpUrl.Builder()
.scheme("https")
.host("example.com")
.build()
),
quotaAvailable = 1024 * 1024 * 1024,
quotaUsed = 512 * 1024 * 1024
),
WebDavMountWithQuota(
mount = WebDavMount(
id = 1,
name = "Preview Webdav Mount 2",
url = HttpUrl.Builder()
.scheme("https")
.host("example.com")
.build()
),
quotaAvailable = 1024 * 1024 * 1024,
quotaUsed = 512 * 1024 * 1024
)
),
refreshingQuota = true
)
}
}
@Composable
@Preview
fun WebdavMountsItem_Preview() {
AppTheme {
WebdavMountsItem(
info = WebDavMountWithQuota(
mount = WebDavMount(
id = 0,
name = "Preview Webdav Mount 1",
url = HttpUrl.Builder()
.scheme("https")
.host("example.com")
.build()
),
quotaAvailable = 1024 * 1024 * 1024,
quotaUsed = 512 * 1024 * 1024
),
WebDavMountWithQuota(
mount = WebDavMount(
id = 1,
name = "Preview Webdav Mount 2",
name = "Preview Webdav Mount",
url = HttpUrl.Builder()
.scheme("https")
.host("example.com")
@@ -391,28 +413,8 @@ fun WebdavMountsScreen_Preview_TwoMounts() {
quotaAvailable = 1024 * 1024 * 1024,
quotaUsed = 512 * 1024 * 1024
)
),
refreshingQuota = true
)
}
@Composable
@Preview
fun WebdavMountsItem_Preview() {
WebdavMountsItem(
info = WebDavMountWithQuota(
mount = WebDavMount(
id = 0,
name = "Preview Webdav Mount",
url = HttpUrl.Builder()
.scheme("https")
.host("example.com")
.build()
),
quotaAvailable = 1024 * 1024 * 1024,
quotaUsed = 512 * 1024 * 1024
)
)
}
}

View File

@@ -1,35 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.widget
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
@OptIn(ExperimentalTextApi::class)
@Composable
fun ClickableTextWithLink(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default
) {
val uriHandler = LocalUriHandler.current
ClickableText(
text = text,
style = style.copy(color = LocalContentColor.current),
modifier = modifier
) { index ->
// Get the tapped position, and check if there's any link
text.getUrlAnnotations(index, index).firstOrNull()?.item?.url?.let { url ->
uriHandler.openUri(url)
}
}
}

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