Compare commits

...

597 Commits

Author SHA1 Message Date
Ricki Hirner
38bad81c55 [WIP] Split into packages 2026-02-05 14:36:39 +01:00
Ricki Hirner
31a7ad0939 Move OSE code to separate package
- Move DebugInfoCrashHandler.kt to com.davx5.ose
- Move StandardLoginTypePage.kt to com.davx5.ose.ui.setup
- Move StandardLoginTypesProvider.kt to com.davx5.ose.ui.setup
- Move CustomCertManagerModule.kt to com.davx5.ose.di
- Move OseIntroPageFactory.kt to com.davx5.ose.ui.intro
- Move OseColorSchemesModule.kt to com.davx5.ose.di
- Move OseFlavorModule.kt to com.davx5.ose.di
- Move OpenSourceLicenseInfoProvider.kt to com.davx5.ose.ui.about
- Move OseTheme.kt to com.davx5.ose.ui
2026-02-05 14:10:14 +01:00
Ricki Hirner
5e84648fb4 Replace BuildConfig.allowCustomCerts by DI (#1971)
* Update IntroScreen colors

- Replace M3ColorScheme with MaterialTheme.colorScheme
- Update background and icon colors to use MaterialTheme

* Update color scheme references

- Replace `M3ColorScheme.primaryLight` with `MaterialTheme.colorScheme.primary` in `WelcomePage.kt` and `AccountsDrawerHandler.kt`.

* Update AppTheme to accept custom color schemes

- Add `lightColorScheme` and `darkColorScheme` parameters
- Replace hardcoded color schemes with the new parameters

* Add color scheme dependency injection

- Add `LightColorScheme` and `DarkColorScheme` qualifiers
- Create `OseColorSchemes` module for providing color schemes
- Update `AppTheme` to use injected color schemes

* Update glance material dependency to material3

- Update `androidx.glance.material` to `androidx.glance.material3`
- Adjust imports and usage in `IconSyncButtonWidget.kt` and `LabeledSyncButtonWidget.kt` to use `GlanceTheme` and `ColorProviders` for color schemes
- Replace deprecated `ColorProvider` with `GlanceTheme.colors` for primary and onPrimary colors

* Refactor widget receivers and widgets to use dependency injection more properly

- Update `LabeledSyncButtonWidgetReceiver` and `IconSyncButtonWidgetReceiver` to use Hilt for dependency injection.
- Inject `SyncWidgetModel`, `LightColorScheme`, and `DarkColorScheme` into both widget receivers.
- Remove the use of `EntryPoint` and `EntryPointAccessors` from `LabeledSyncButtonWidget` and `IconSyncButtonWidget`.
- Pass injected dependencies directly to the widget constructors.

* Rename ThemeColors to OseTheme

- Update imports and references to use OseTheme
- Rename object M3ColorScheme to OseTheme

* Move AppTheme to ui.composable package because it's a reusable Composable

* Update AboutApp to use dynamic version info instead of BuildConfig

- Pass versionName and versionCode from AboutModel to AboutApp
- Remove dependency on BuildConfig in AboutApp
- Update AboutApp_Preview with sample version info

* Update URI statistics parameters

- Update `withStatParams` to include package name and version
- Replace `BuildConfig.APPLICATION_ID` with `context.packageName`
- Add context parameter to `withStatParams` in various activities

* Don't depend on BuildConfig for application name and version

- Introduce `ProductIds` for managing product IDs and User-Agent
- Update various classes to use `ProductIds` for product ID generation
- Move `TextTable` class from `at.bitfire.davdroid` to `at.bitfire.davdroid.util`

* Refactor OAuth classes for dependency injection

- Convert `OAuthFastmail` and `OAuthGoogle` to injectable classes
- Update `FastmailLoginModel` and `GoogleLoginModel` to use injected instances
- Move `redirectUri` initialization to `OAuthIntegration` constructor

* Adapt DI

- Move CustomCertManagerModule to ose configuration
- Move coroutine scopes to scope package

* minor changes

* Remove custom certificate build config

- Remove `allowCustomCerts` build config field
- Replace `@Singleton` with `@Reusable` in CustomCertManagerModule

* Update imports and LogcatHandler initialization

- Update imports to use scoped dispatchers
- Replace BuildConfig.APPLICATION_ID with javaClass.name in LogcatHandler initialization
2026-02-05 13:59:31 +01:00
Ricki Hirner
490abcb88a Reduce BuildConfig dependencies (#1969)
* Update IntroScreen colors

- Replace M3ColorScheme with MaterialTheme.colorScheme
- Update background and icon colors to use MaterialTheme

* Update color scheme references

- Replace `M3ColorScheme.primaryLight` with `MaterialTheme.colorScheme.primary` in `WelcomePage.kt` and `AccountsDrawerHandler.kt`.

* Update AppTheme to accept custom color schemes

- Add `lightColorScheme` and `darkColorScheme` parameters
- Replace hardcoded color schemes with the new parameters

* Add color scheme dependency injection

- Add `LightColorScheme` and `DarkColorScheme` qualifiers
- Create `OseColorSchemes` module for providing color schemes
- Update `AppTheme` to use injected color schemes

* Update glance material dependency to material3

- Update `androidx.glance.material` to `androidx.glance.material3`
- Adjust imports and usage in `IconSyncButtonWidget.kt` and `LabeledSyncButtonWidget.kt` to use `GlanceTheme` and `ColorProviders` for color schemes
- Replace deprecated `ColorProvider` with `GlanceTheme.colors` for primary and onPrimary colors

* Refactor widget receivers and widgets to use dependency injection more properly

- Update `LabeledSyncButtonWidgetReceiver` and `IconSyncButtonWidgetReceiver` to use Hilt for dependency injection.
- Inject `SyncWidgetModel`, `LightColorScheme`, and `DarkColorScheme` into both widget receivers.
- Remove the use of `EntryPoint` and `EntryPointAccessors` from `LabeledSyncButtonWidget` and `IconSyncButtonWidget`.
- Pass injected dependencies directly to the widget constructors.

* Rename ThemeColors to OseTheme

- Update imports and references to use OseTheme
- Rename object M3ColorScheme to OseTheme

* Move AppTheme to ui.composable package because it's a reusable Composable

* Update AboutApp to use dynamic version info instead of BuildConfig

- Pass versionName and versionCode from AboutModel to AboutApp
- Remove dependency on BuildConfig in AboutApp
- Update AboutApp_Preview with sample version info

* Update URI statistics parameters

- Update `withStatParams` to include package name and version
- Replace `BuildConfig.APPLICATION_ID` with `context.packageName`
- Add context parameter to `withStatParams` in various activities

* Don't depend on BuildConfig for application name and version

- Introduce `ProductIds` for managing product IDs and User-Agent
- Update various classes to use `ProductIds` for product ID generation
- Move `TextTable` class from `at.bitfire.davdroid` to `at.bitfire.davdroid.util`

* Refactor OAuth classes for dependency injection

- Convert `OAuthFastmail` and `OAuthGoogle` to injectable classes
- Update `FastmailLoginModel` and `GoogleLoginModel` to use injected instances
- Move `redirectUri` initialization to `OAuthIntegration` constructor
2026-02-05 12:20:21 +01:00
Ricki Hirner
cca12e79d8 [UI] Properly provide color schemes over DI (#1966)
* Update IntroScreen colors

- Replace M3ColorScheme with MaterialTheme.colorScheme
- Update background and icon colors to use MaterialTheme

* Update color scheme references

- Replace `M3ColorScheme.primaryLight` with `MaterialTheme.colorScheme.primary` in `WelcomePage.kt` and `AccountsDrawerHandler.kt`.

* Update AppTheme to accept custom color schemes

- Add `lightColorScheme` and `darkColorScheme` parameters
- Replace hardcoded color schemes with the new parameters

* Add color scheme dependency injection

- Add `LightColorScheme` and `DarkColorScheme` qualifiers
- Create `OseColorSchemes` module for providing color schemes
- Update `AppTheme` to use injected color schemes

* Update glance material dependency to material3

- Update `androidx.glance.material` to `androidx.glance.material3`
- Adjust imports and usage in `IconSyncButtonWidget.kt` and `LabeledSyncButtonWidget.kt` to use `GlanceTheme` and `ColorProviders` for color schemes
- Replace deprecated `ColorProvider` with `GlanceTheme.colors` for primary and onPrimary colors

* Refactor widget receivers and widgets to use dependency injection more properly

- Update `LabeledSyncButtonWidgetReceiver` and `IconSyncButtonWidgetReceiver` to use Hilt for dependency injection.
- Inject `SyncWidgetModel`, `LightColorScheme`, and `DarkColorScheme` into both widget receivers.
- Remove the use of `EntryPoint` and `EntryPointAccessors` from `LabeledSyncButtonWidget` and `IconSyncButtonWidget`.
- Pass injected dependencies directly to the widget constructors.

* Rename ThemeColors to OseTheme

- Update imports and references to use OseTheme
- Rename object M3ColorScheme to OseTheme

* Move AppTheme to ui.composable package because it's a reusable Composable

* Always use Light Color Scheme for certain intro UI

- Inject `lightColorScheme` in `IntroActivity`
- Pass `lightColorScheme` to `IntroScreen`
- Use `lightColorScheme` for background and color in `IntroScreen` and `WelcomePage`

* Minor syntax
2026-02-05 11:43:55 +01:00
Ricki Hirner
915cf73027 Update version to 4.5.9 2026-02-04 10:58:57 +01:00
Ricki Hirner
53773eaf83 Go back to 4.5.9-rc.2 (4.5.9 with version code 405090003 was never released) 2026-02-03 16:00:55 +01:00
Ricki Hirner
9cd685982d Update synctools to correctly process tel: URIs in vCards (#1963) 2026-02-03 15:59:46 +01:00
Sunik Kupfer
d4902e84ce [synctools] Tasks rewrite: Use reader/writer (#1959)
* Update synctools; Use writer/reader

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-02-03 15:22:15 +01:00
dependabot[bot]
ec485fcfa5 Bump the app-dependencies group with 8 updates (#1961)
Bumps the app-dependencies group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| gradle-wrapper | `9.3.0` | `9.3.1` |
| androidx.activity:activity-compose | `1.12.2` | `1.12.3` |
| androidx.compose:compose-bom | `2026.01.00` | `2026.01.01` |
| androidx.paging:paging-runtime-ktx | `3.3.6` | `3.4.0` |
| androidx.paging:paging-compose | `3.3.6` | `3.4.0` |
| androidx.work:work-runtime-ktx | `2.11.0` | `2.11.1` |
| androidx.work:work-testing | `2.11.0` | `2.11.1` |
| [com.google.devtools.ksp](https://github.com/google/ksp) | `2.3.4` | `2.3.5` |


Updates `gradle-wrapper` from 9.3.0 to 9.3.1

Updates `androidx.activity:activity-compose` from 1.12.2 to 1.12.3

Updates `androidx.compose:compose-bom` from 2026.01.00 to 2026.01.01

Updates `androidx.paging:paging-runtime-ktx` from 3.3.6 to 3.4.0

Updates `androidx.paging:paging-compose` from 3.3.6 to 3.4.0

Updates `androidx.paging:paging-compose` from 3.3.6 to 3.4.0

Updates `androidx.work:work-runtime-ktx` from 2.11.0 to 2.11.1

Updates `androidx.work:work-testing` from 2.11.0 to 2.11.1

Updates `androidx.work:work-testing` from 2.11.0 to 2.11.1

Updates `com.google.devtools.ksp` from 2.3.4 to 2.3.5
- [Release notes](https://github.com/google/ksp/releases)
- [Commits](https://github.com/google/ksp/compare/2.3.4...2.3.5)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.12.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.01.01
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: androidx.paging:paging-runtime-ktx
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.paging:paging-compose
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.paging:paging-compose
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-runtime-ktx
  dependency-version: 2.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.devtools.ksp
  dependency-version: 2.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 14:57:10 +01:00
Ricki Hirner
5709aaa2e5 Bump version to 4.5.9 2026-02-03 12:12:05 +01:00
Ricki Hirner
a19c397ef6 Sync files with davx5 repo (#1958)
* Sync changes with davx5 repo

* Remove unnecessary test launcher icons
2026-02-02 12:30:04 +01:00
Ricki Hirner
dad4298dd5 Bump version to 4.5.9-rc.1 2026-02-02 10:12:52 +01:00
Weblate (bot)
8e78e6e3ac Translations update from Hosted Weblate (#1955)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/et/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/nl/

* Added translation using Weblate (Lithuanian)

* Translated using Weblate (Lithuanian)

Currently translated at 7.6% (33 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/lt/

* Translated using Weblate (Georgian)

Currently translated at 84.6% (364 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/ka/

* Translated using Weblate (Romanian)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/ro/

* Translated using Weblate (Italian)

Currently translated at 88.1% (379 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (4 of 4 strings)

Translation: davx5/DAVx⁵ app metadata (for F-Droid)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-fastlane/it/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (437 of 437 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/et/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (437 of 437 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/zh_Hans/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (437 of 437 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/nl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (437 of 437 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/pt_BR/

---------

Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Stephan Paternotte <stephan@paternottes.net>
Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: Igor Sorocean <sorocean.igor@gmail.com>
Co-authored-by: Alì Mortacci <newscpq@vivaldi.net>
2026-02-02 10:12:10 +01:00
Ricki Hirner
fc878d519f Update synctools to process phone numbers with VALUE=URI (#1956) 2026-02-02 10:11:40 +01:00
Ricki Hirner
45d5d809fc Bump version to 4.5.9-alpha.2 2026-01-29 14:17:59 +01:00
Ricki Hirner
ef1d90f740 Cache SSLSocketFactories to allow okhttp HTTPS connection reuse (#1942)
* Reuse CustomCertManager

- Update bitfire-cert4android to 75cc6913fd
- Refactor HttpClientBuilder to use Optional for customTrustManager and customHostnameVerifier
- Add CustomCertManagerModule for dependency injection

* Implement connection security manager for HTTP client

- Introduce `ConnectionSecurityManager` and `ConnectionSecurityContext` classes
- Refactor `HttpClientBuilder` to use the new security manager for SSL context setup

* [WIP] Cache SSLContext by certificate alias

- Add context cache using Guava CacheBuilder
- Cache SSLContext in getContext method

* Update comments in HttpClientBuilder.kt for clarity

* Update ConnectionSecurityManager to use SSLSocketFactory caching

* Refactor socket factory caching logic for better clarity

* Add tests

* Refactor socket factory cache to store only SSLSocketFactory

* Minor changes
- Change socketFactoryCache to use LinkedHashMap instead of ConcurrentHashMap
- Update cache key handling to use String? instead of Optional<String>

* Add tests for caching

* Add logging

* Indenting

* Minor simplification

* Fix tests
2026-01-29 14:09:09 +01:00
Arnau Mora
5efcbfc5a3 Set Calendars.IS_PRIMARY to 0 by default (#1945)
* Start setting `IS_PRIMARY`

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

* Set `IS_PRIMARY` to `0`

* Remove unused injection

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

* Improve explanation

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2026-01-29 12:30:23 +01:00
Sunik Kupfer
4f3ff69b43 Update synctools (#1952)
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-29 12:18:29 +01:00
Ricki Hirner
afe00c275e Update CODEOWNERS (#1949) 2026-01-28 13:18:28 +01:00
Sunik Kupfer
03c4aa9938 Ignore AccountSettingsMigration21Test.testCancelsSyncAndClearsPendingState with flaky behaviour in CI (#1948)
Ignore test with flaky behaviour in CI

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-28 11:25:00 +01:00
dependabot[bot]
63a5359c06 Bump the app-dependencies group with 7 updates (#1944)
Bumps the app-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [io.ktor:ktor-client-content-negotiation](https://github.com/ktorio/ktor) | `3.3.3` | `3.4.0` |
| [io.ktor:ktor-client-core](https://github.com/ktorio/ktor) | `3.3.3` | `3.4.0` |
| [io.ktor:ktor-client-okhttp](https://github.com/ktorio/ktor) | `3.3.3` | `3.4.0` |
| [io.ktor:ktor-serialization-kotlinx-json](https://github.com/ktorio/ktor) | `3.3.3` | `3.4.0` |
| [io.mockk:mockk](https://github.com/mockk/mockk) | `1.14.7` | `1.14.9` |
| [io.mockk:mockk-android](https://github.com/mockk/mockk) | `1.14.7` | `1.14.9` |
| [org.robolectric:robolectric](https://github.com/robolectric/robolectric) | `4.16` | `4.16.1` |


Updates `io.ktor:ktor-client-content-negotiation` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.ktor:ktor-client-core` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.ktor:ktor-client-okhttp` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.ktor:ktor-serialization-kotlinx-json` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.ktor:ktor-client-core` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.ktor:ktor-client-okhttp` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.ktor:ktor-serialization-kotlinx-json` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.3...3.4.0)

Updates `io.mockk:mockk` from 1.14.7 to 1.14.9
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.7...1.14.9)

Updates `io.mockk:mockk-android` from 1.14.7 to 1.14.9
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.7...1.14.9)

Updates `io.mockk:mockk-android` from 1.14.7 to 1.14.9
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.7...1.14.9)

Updates `org.robolectric:robolectric` from 4.16 to 4.16.1
- [Release notes](https://github.com/robolectric/robolectric/releases)
- [Commits](https://github.com/robolectric/robolectric/compare/robolectric-4.16...robolectric-4.16.1)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-content-negotiation
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-client-core
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-client-okhttp
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-serialization-kotlinx-json
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-client-core
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-client-okhttp
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-serialization-kotlinx-json
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk
  dependency-version: 1.14.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: org.robolectric:robolectric
  dependency-version: 4.16.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 17:26:59 +01:00
Weblate (bot)
89a7cd2885 Translations update from Hosted Weblate (#1943)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/et/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/nl/

* Added translation using Weblate (Lithuanian)

* Translated using Weblate (Lithuanian)

Currently translated at 7.6% (33 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/lt/

* Translated using Weblate (Georgian)

Currently translated at 84.6% (364 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/ka/

* Translated using Weblate (Romanian)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/ro/

* Translated using Weblate (Italian)

Currently translated at 88.1% (379 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (4 of 4 strings)

Translation: davx5/DAVx⁵ app metadata (for F-Droid)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-fastlane/it/

---------

Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Stephan Paternotte <stephan@paternottes.net>
Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: Igor Sorocean <sorocean.igor@gmail.com>
Co-authored-by: Alì Mortacci <newscpq@vivaldi.net>
2026-01-25 18:14:56 +01:00
Ricki Hirner
db25570581 Bump version to 4.5.9-alpha.1 2026-01-25 17:52:15 +01:00
Arnau Mora
3de34e53d0 Migrate translation contributors credits to Weblate (#1918)
* Update loading method for Weblate

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

* Add fetch script

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

* Add test function

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

* Add credits from Transifex

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

* Add workflow for updating Weblate credits

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

* Improve styling

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

* Add line break at the end

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

* Filter Ricki in translation contributions

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

* Display Transifex translations

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

* Modify PRs, not the base branch

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

* Fix paddings

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

* Rename weblateTranslations flow and loadWeblateTranslations function

* Rename variables and functions from `translations` to `translators`

* - Move AboutActivity.Model class to AboutModel

* Rename translators files

* Minor renaming

* Refactor sorting logic into `sortTranslators` function

* Rename script to fetch Weblate translators

* - Remove `AboutActivityTest` class
- Move `loadTransifexTranslators` function to `AboutModel`
- Update `loadWeblateTranslators` function in `AboutModel`

* Update Weblate workflow

* - Combine Weblate and Transifex translators into a single list
- Update AboutActivity to use the combined list
- Add tests for the new functionality

* Merge Transifex and Weblate translators by username, don't show language, add Engage widget

* Extract TranslationsTab

* AboutModel: update tests

* Add thanks message for translation contributors

* Move translation credits to Weblate

- Update `OpenSourceLicenseInfoProvider` path
- Add new strings for translations credits
- Update `TranslationsTab` with new strings

* Add Accept-Language, update Context

* Update Weblate Translators Workflow

- Allow workflow to run on specific branch for testing
- Remove unnecessary fetch-depth comment

* Update Weblate Translators Workflow (2)

* Remove workflow

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2026-01-25 17:51:34 +01:00
Ricki Hirner
af084fb5d0 Add "Backups reminder" intro screen (#1937)
* Add BackupsPage to intro UI

* Add BackupsPage UI

- Implement BackupsPage composable with UI elements
- Add strings for backups reminder and acceptance

* Add to "Reset hints"

* Add another paragraph

* Update backup screen hints

- Update backups reminder text
- Remove redundant versioning note
- Update acceptance message
2026-01-25 17:48:09 +01:00
Ricki Hirner
47685e6693 [WebDAV] Rewrite COPY/MOVE (including rename) to Ktor (#1940)
* [WebDAV] Refactor RenameDocumentOperation to Ktor

- Update imports to use Ktor-based classes
- Refactor `RenameDocumentOperation` to use Ktor HTTP client
- Add support for both HttpException types in `throwForDocumentProvider`

* Rewrite CopyDocumentOperation.kt to Ktor

* Refactor URLBuilder usage

- Update URLBuilder usage in RenameDocumentOperation.kt
- Update URLBuilder usage in CopyDocumentOperation.kt
- Update URLBuilder usage in MoveDocumentOperation.kt

* - Pass `ioDispatcher` to `runBlocking` in WebDAV operations
- Refactor timeout configuration in HttpClientBuilder for reusability

* Add logging to DocumentProviderUtils

- Introduce a logger instance
- Log URI when notifying folder changes
2026-01-24 19:28:50 +01:00
Ricki Hirner
2c7b36ecd5 Use Ktor for Push registration (#1930)
* Replace OkHttp with Ktor for push notifications

* Use Ktor HttpHeaders for Location and Expires
2026-01-23 10:27:16 +01:00
Ricki Hirner
cf80b11808 [WebDAV] Rewrite OpenDocumentThumbnailOperation to Ktor (#1931)
* Add Ktor HTTP client support

- Introduce `buildKtor` method in `DavHttpClientBuilder` for creating Ktor HTTP clients.
- Update `OpenDocumentThumbnailOperation` to use Ktor for downloading and creating thumbnails.

* Refactor HttpClientBuilder creation

- Extract common logic into `createBuilder` method
- Update `build` and `buildKtor` methods to use `createBuilder`

* Refactor OpenDocumentThumbnailOperation

- Remove unnecessary `withContext` call
- Use `HttpHeaders.Accept` and `ContentType.Image.Any` for HTTP header
- Simplify the function structure

* Refactor thumbnail generation

- Remove redundant `accessScope`
- Simplify and encapsulate thumbnail creation logic
- Ensure proper cancellation handling

* Update OpenDocumentThumbnailOperation logging

- Enhance cancellation log message with document ID
- Improve URL conversion warning message

* Update WebDAV operations and document handling

- Add `@MustBeClosed` annotation to `buildKtor` method in `DavHttpClientBuilder`
- Remove unnecessary imports and update URL conversion in `OpenDocumentThumbnailOperation`
- Add `toKtorUrl` method in `WebDavDocument` for URL conversion

* Use streaming bitmap decoding

* Add comments to OpenDocumentThumbnailOperation for future improvements

---------

Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2026-01-22 17:05:32 +01:00
Sunik Kupfer
18649f711a DmfsTaskList refactoring (#1934)
* DmfsTaskList refactoring

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-21 13:17:34 +01:00
Sunik Kupfer
377a159e75 Ignore test with flaky behaviour in CI (#1936)
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-21 13:17:23 +01:00
Ricki Hirner
393d22f720 AGP 9.0: update Hilt, remove Kotlin Android plugin (#1935)
- Remove `android.builtInKotlin` from `gradle.properties`
- Update Hilt version to 2.59
- Remove Kotlin Android plugin from `libs.versions.toml` and build scripts
2026-01-21 11:28:40 +01:00
dependabot[bot]
5b12ecf6b6 Bump the app-dependencies group across 1 directory with 2 updates (#1933)
Bumps the app-dependencies group with 2 updates in the / directory: androidx.compose:compose-bom and [dnsjava:dnsjava](https://github.com/dnsjava/dnsjava).


Updates `androidx.compose:compose-bom` from 2025.12.01 to 2026.01.00

Updates `dnsjava:dnsjava` from 3.6.3 to 3.6.4
- [Release notes](https://github.com/dnsjava/dnsjava/releases)
- [Changelog](https://github.com/dnsjava/dnsjava/blob/master/Changelog)
- [Commits](https://github.com/dnsjava/dnsjava/compare/v3.6.3...v3.6.4)

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.01.00
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: dnsjava:dnsjava
  dependency-version: 3.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 16:34:51 +01:00
Ricki Hirner
f8f6134640 Update AGP to 9.0.0 (#1929)
* Update gradle wrapper

* Update AGP to 9.0.0 (legacy mode) and synctools (which now also uses AGP 9.0.0)
2026-01-20 12:45:58 +01:00
Ricki Hirner
0f7908da23 Remove Transifex config/scripts (#1924) 2026-01-19 10:59:34 +01:00
Weblate (bot)
40741f52e1 Translations update from Hosted Weblate (#1928)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/et/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/nl/

* Added translation using Weblate (Lithuanian)

* Translated using Weblate (Lithuanian)

Currently translated at 7.6% (33 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/lt/

* Translated using Weblate (Georgian)

Currently translated at 84.6% (364 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/ka/

---------

Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Stephan Paternotte <stephan@paternottes.net>
Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
2026-01-19 09:48:24 +01:00
Ricki Hirner
210a03bd1a Bump version to 4.5.8 2026-01-19 09:35:38 +01:00
Ricki Hirner
714c92b8d9 Bump version to 4.5.8-rc.2 2026-01-16 15:45:48 +01:00
Ricki Hirner
5ea937a0f9 Update dav4jvm to catch URLDecodeException when converting String to Ktor Url (#1923) 2026-01-16 15:43:54 +01:00
Ricki Hirner
126b742887 Decode data URIs of vCard 3 PHOTOs (#1921)
* Rename ResourceDownloader to ResourceRetriever (because it should support `data` URLs that don't have to be downloaded)

* Update `ResourceRetriever` to handle data URIs and HTTP/HTTPS URLs

* Handle invalid data URIs
2026-01-16 11:52:49 +01:00
Weblate (bot)
2de7e09c82 Translations update from Hosted Weblate (#1917)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ app strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/et/

---------

Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
2026-01-14 12:03:49 +01:00
Ricki Hirner
0312f59aab Bump version to 4.5.8-rc.1 2026-01-14 12:00:05 +01:00
Weblate (bot)
b3682ded1a Translations update from Hosted Weblate (#1915)
* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: davx5/DAVx⁵ strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ strings (main)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-strings/zh_Hans/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (4 of 4 strings)

Translation: davx5/DAVx⁵ app metadata (for F-Droid)
Translate-URL: https://hosted.weblate.org/projects/davx5/davx5-ose-fastlane/ca/

---------

Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Arnau Mora <arnyminer.z@gmail.com>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2026-01-13 16:00:16 +01:00
Weblate (bot)
529378000a Translations update from Hosted Weblate (#1913)
* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: davx5/DAVx⁵ Main Strings
Translate-URL: https://hosted.weblate.org/projects/davx5/davx-main-strings/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (430 of 430 strings)

Translation: davx5/DAVx⁵ Main Strings
Translate-URL: https://hosted.weblate.org/projects/davx5/davx-main-strings/zh_Hans/

---------

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
2026-01-13 12:41:45 +01:00
Ricki Hirner
8644c94c34 [Backport from davx5] Remove empty fa-rIR translations 2026-01-13 12:07:08 +01:00
Ricki Hirner
5357bdefb8 Fastlane app descriptions: use fr-FR translation for French (#1912) 2026-01-12 14:43:36 +01:00
Arnau Mora
07b646e4e7 Remove r from lang names as per Fastlane requirement (#1897)
* Remove `r` from lang names as per Fastlane requirement

* Add `lang_map` to fastlane resources

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2026-01-12 14:31:09 +01:00
Sunik Kupfer
77cb3e659d Improve pagination (#1911)
* Cache paging data flow in view model

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Replace let with early return

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move only-personal filtering decision to repository

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move companion object to the end of class

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use default dispatcher for account settings interaction

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add androidx prefixes

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move cachedIn to use-case

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-12 13:48:02 +01:00
Ricki Hirner
42d65c872e Explain clear-text traffic in network security policy (bitfireAT/davx5#760)
Allow clear-text traffic for all variants; more documentation
2026-01-12 10:43:29 +01:00
Sunik Kupfer
c3fd28f820 Return null when owner account does not exist to skip flaky test (#1908)
Return null when owner account does not exist

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2026-01-08 12:18:37 +01:00
Ricki Hirner
9ad98e4a16 Enable HTTP connection reuse for okhttp (#1907)
* Enable HTTP connection reuse

- Create a shared OkHttpClient instance for connection pooling
- Utilize sharedOkHttpClient.newBuilder() for new client instances

* Add test

* KDoc clarification
2026-01-06 13:28:48 +01:00
dependabot[bot]
27c1be948f Bump org.unifiedpush.android:connector from 3.1.2 to 3.2.0 in the app-dependencies group (#1906)
Bump org.unifiedpush.android:connector in the app-dependencies group

Bumps the app-dependencies group with 1 update: org.unifiedpush.android:connector.


Updates `org.unifiedpush.android:connector` from 3.1.2 to 3.2.0

---
updated-dependencies:
- dependency-name: org.unifiedpush.android:connector
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 16:50:08 +01:00
Ricki Hirner
ac7ef1a7e5 Update README and CONTRIBUTING (#1905) 2026-01-05 14:26:10 +01:00
Ricki Hirner
e0eb13f57b CodeQL: run for main branch PRs (#1904)
CodeQL: run for main branch PRs, adapt build command
2026-01-05 12:27:25 +01:00
dependabot[bot]
85c4fc76f2 Bump the app-dependencies group across 1 directory with 5 updates (#1902)
Bumps the app-dependencies group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.activity:activity-compose | `1.12.1` | `1.12.2` |
| androidx.compose:compose-bom | `2025.12.00` | `2025.12.01` |
| [com.mikepenz:aboutlibraries-compose-m3](https://github.com/mikepenz/AboutLibraries) | `13.1.0` | `13.2.1` |
| com.mikepenz.aboutlibraries.plugin.android | `13.1.0` | `13.2.1` |
| [com.google.devtools.ksp](https://github.com/google/ksp) | `2.3.3` | `2.3.4` |



Updates `androidx.activity:activity-compose` from 1.12.1 to 1.12.2

Updates `androidx.compose:compose-bom` from 2025.12.00 to 2025.12.01

Updates `com.mikepenz:aboutlibraries-compose-m3` from 13.1.0 to 13.2.1
- [Release notes](https://github.com/mikepenz/AboutLibraries/releases)
- [Commits](https://github.com/mikepenz/AboutLibraries/compare/13.1.0...13.2.1)

Updates `com.mikepenz.aboutlibraries.plugin.android` from 13.1.0 to 13.2.1

Updates `com.google.devtools.ksp` from 2.3.3 to 2.3.4
- [Release notes](https://github.com/google/ksp/releases)
- [Commits](https://github.com/google/ksp/compare/2.3.3...2.3.4)

Updates `com.mikepenz.aboutlibraries.plugin.android` from 13.1.0 to 13.2.1

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.12.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.12.01
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.mikepenz:aboutlibraries-compose-m3
  dependency-version: 13.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.mikepenz.aboutlibraries.plugin.android
  dependency-version: 13.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.google.devtools.ksp
  dependency-version: 2.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.mikepenz.aboutlibraries.plugin.android
  dependency-version: 13.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-04 12:38:52 +01:00
Ricki Hirner
6bd9422f3b [CI] Don't require build cache secrets (#1900)
* Don't require build cache secrets

* Use our labels for Dependabot PRs
2026-01-04 12:06:28 +01:00
Ricki Hirner
9754770238 [CI] Fix pre-populating configuration cache 2025-12-18 10:36:29 +01:00
Ricki Hirner
6be15fd366 [CI] Actually use configuration cache (#1891)
* Cache configurations per job

* Use separate job for Dependency submission

* Use GRADLE_OPTS to enable build and configuration cache

* Test .android

* Cache .android for configuration cache

* Disable CodeQL for PRs

* Fix AVD path
2025-12-18 10:27:01 +01:00
Ricki Hirner
0cb27f0c2f [CI] Add gradle remote build cache (bitfireAT/davx5#752)
* [CI] Add gradle remote build cache

* Update workflow

* Don't cache local build cache; pre-populate configuration cache

* Allow configuration caching of tasks

* Free some disk space before running instrumented tests; cache whole .android (not only .android/avd)

* Allow branches to update configuration cache

* Use dry run to pre-populate configuration cache

* Test runs: don't cache

* Fix remote build cache configuration for non-CI builds

* Add comment
2025-12-17 18:06:17 +01:00
Sunik Kupfer
776305bd12 Rename dismissInvalidResource to dismissCollectionError (#1887)
Rename method for clarity

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-17 13:14:06 +01:00
dependabot[bot]
01f54df3c0 [CI] Bump actions/cache from 4 to 5 in the ci-actions group (#1886)
Bumps the ci-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 13:38:54 +01:00
Ricki Hirner
4944ce59b1 Version bump to 4.5.8-alpha.1 2025-12-12 15:32:30 +01:00
Sunik Kupfer
0e455d8371 LocalTaskList: Stop subclassing DmfsTaskList (#1882)
* LocalTaskList: Stop subclassing DmfsTaskList

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

# Conflicts:
#	gradle/libs.versions.toml

* Dont touch agp

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-11 16:22:02 +01:00
Ricki Hirner
a938b511cd Nextcloud Login Flow: handle non-success status codes (#1878)
* Nextcloud Login Flow: handle non-success status codes

* Update error message to use class name when localized message is null

* Update dav4jvm to get HTTP reason phrases in HttpException
2025-12-11 15:30:32 +01:00
Ricki Hirner
d32b86789b Update AGP 2025-12-11 15:13:15 +01:00
Sunik Kupfer
84d58f73db Assume initial state for test updatesOwnerAccount (#1874)
* Assume initial state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Enhance comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Don't pass provider

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-11 14:16:03 +01:00
dependabot[bot]
cc43998148 Bump the app-dependencies group with 4 updates (#1867)
* Bump the app-dependencies group with 4 updates

Bumps the app-dependencies group with 4 updates: androidx.activity:activity-compose, androidx.compose:compose-bom, [io.mockk:mockk](https://github.com/mockk/mockk) and [io.mockk:mockk-android](https://github.com/mockk/mockk).


Updates `androidx.activity:activity-compose` from 1.12.0 to 1.12.1

Updates `androidx.compose:compose-bom` from 2025.11.01 to 2025.12.00

Updates `io.mockk:mockk` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.12.00
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Override SDK level

* Suppress lint warnings for LaunchedEffect / Context.getString

* Suppress lint warnings for other Context.getStrings (or replace by stringResource if possible)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-12-11 11:27:37 +01:00
Sunik Kupfer
b354bfebc2 LocalTask: Don't subclass DmfsTask (#1862)
* LocalTask: Don't subclass DmfsTask

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Adapt usages

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-10 14:37:23 +01:00
Sunik Kupfer
29240ea16f Skip flaky test when not moving into anticipated forever pending sync state (#1872)
* Assume we moved into forever pending sync state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-10 10:25:58 +01:00
Ricki Hirner
e7b88f9aa8 Version bump to 4.5.7.1 2025-12-09 11:49:50 +01:00
Arnau Mora
4d71517cde Delegate fileName into DmfsTask.syncId (#1871)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-12-09 10:40:22 +01:00
Ricki Hirner
0d4f154baf Use Ktor for Nextcloud Login Flow (#1817)
* [WIP] Use Ktor for Nextcloud login flow

- Replace OkHttp with Ktor for HTTP requests
- Update URL handling to use Ktor's `Url` class
- Adjust `postForJson` method to use Ktor's HTTP client
- Refactor URL building logic for login flow initiation

* Use Ktor for Nextcloud login flow

- Migrate to Ktor's ContentNegotiation plugin for JSON handling
- Update dependencies and configuration for Ktor serialization
- Refactor `NextcloudLoginFlow` to use Ktor's JSON serialization

* Add tests

* Allow unit tests that mock/use HttpClient without Conscrypt

* KDoc

* Minor fixes

* Use toUrlOrNull from dav4jvm

* Don't change strings in this PR

* Update dav4jvm and synctools
2025-12-08 15:40:39 +01:00
Ricki Hirner
9eb70a5564 Bump version to 4.5.7 2025-12-08 12:19:38 +01:00
Ricki Hirner
24e0a864bd Update AVD caching in CI workflow (#1865)
- Split AVD cache handling into restore and save steps
- Add condition to save AVD cache only if restore misses
2025-12-05 16:25:25 +01:00
Ricki Hirner
10ec0c3b6d Fetch translations from Transifex 2025-12-05 15:20:46 +01:00
Ricki Hirner
bd3349cc38 Bump version to 4.5.7-rc.1 2025-12-05 15:19:43 +01:00
Ricki Hirner
c7bc2b317b Update synctools (#1864)
* Update synctools (fixes #1797, closes #1859)

* Use `com.github.bitfireAT:synctools` because `com.github.bitfireat:synctools` is not available on Jitpack for this commit
2025-12-05 15:16:29 +01:00
Sunik Kupfer
2d10cbb07d Improve closing of content provider in verify account owner test (#1838)
* Optimize imports

* Remove the ignore annotation

* Move provider use out of verify method

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove unnecessary provider.use blocks

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add spaces

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename lambda param provider to client in LocalDataStore implementations

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Enhance kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Improve provider client usage

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Replace calling apply with assignment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove whitespace

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add nullable returns even though they never return null

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Apply WillNotClose annotation to client parameter instead of method

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-02 10:34:39 +01:00
dependabot[bot]
88928792af Bump the app-dependencies group with 2 updates (#1860)
Bumps the app-dependencies group with 2 updates: [io.ktor:ktor-client-core](https://github.com/ktorio/ktor) and [io.ktor:ktor-client-okhttp](https://github.com/ktorio/ktor).


Updates `io.ktor:ktor-client-core` from 3.3.2 to 3.3.3
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.2...3.3.3)

Updates `io.ktor:ktor-client-okhttp` from 3.3.2 to 3.3.3
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.2...3.3.3)

Updates `io.ktor:ktor-client-okhttp` from 3.3.2 to 3.3.3
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-core
  dependency-version: 3.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-client-okhttp
  dependency-version: 3.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.ktor:ktor-client-okhttp
  dependency-version: 3.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 18:36:53 +01:00
Sunik Kupfer
b5e8c80db1 Try to fix pending sync state test failures by using a hot flow (#1839)
* Remove the ignore annotation

* Turn inPendingState in to a hot state flow for the test duration

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename methods registering the sync state observer

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-01 14:40:52 +01:00
Ricki Hirner
6f09f55e1a Update synctools (ignores DTEND < DTSTART and inverts negative event durations) (#1858) 2025-12-01 12:02:42 +01:00
Ricki Hirner
b08f10a98f HttpClientBuilder: clarify documentation for authDomain (#1857)
* Update authentication domain parameter

- Rename `onlyHost` to `authDomain` in `fromAccount`
- Update `authenticate` method to use `domain` instead of `host`
- Clarify documentation for `authDomain` parameter

* More KDoc

* Fix other calls / tests
2025-12-01 11:54:36 +01:00
Sunik Kupfer
a3a952d875 LocalTaskList/LocalTask: Consume fields provided by synctools (#1811)
* Move companion object to end of class

* Update synctools

* Make DmfsTaskList final

* Use DmfsTaskList SyncState

* Drop fields now provided in DmfsTask and adapt constructors

* Use column constants from DmfsTask instead

* Use DmfsTask column constants

* Update synctools

* Don't handle scheduleTag

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-11-27 16:35:17 +01:00
Ricki Hirner
e9fc570895 Extract ResourceDownloader from ContactsSyncManager, add tests (#1849)
* Add ResourceDownloader and tests

- Introduce `ResourceDownloader` class for downloading external resources
- Add unit tests for `ResourceDownloader`
- Refactor `ContactsSyncManager` to use `ResourceDownloader`

* KDoc

- Add detailed documentation for `download` method
- Clarify authentication handling and return behavior

* Minor changes
2025-11-27 16:10:33 +01:00
Ricki Hirner
098b7d5b12 Log warning instead of throwing exception on multiple build calls (#1847)
Log warning instead of throwing IllegalStateException on multiple build calls

- Change `build()` to log a warning instead of throwing an exception on subsequent calls.
- Change `buildKtor()` to log a warning instead of throwing an exception on subsequent calls.
2025-11-27 16:08:52 +01:00
Ricki Hirner
a38dc29cca Update WebDAV property names (#1841)
* Update WebDAV property names according to new dav4jvm naming scheme

* Use new supported-report-set; fix `MaxResourceSize` property reference in CalendarSyncManager

* Remove comment
2025-11-27 11:46:10 +01:00
Ricki Hirner
84b9a14ba1 Update version to 4.5.6.1 2025-11-27 11:15:39 +01:00
Arnau Mora
cda95dc789 Fix HTTP Client provider for contact resource sync from URL (#1844)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-11-27 11:15:06 +01:00
dependabot[bot]
f64882ca2a Bump the app-dependencies group with 15 updates (#1840)
Bumps the app-dependencies group with 15 updates:

| Package | From | To |
| --- | --- | --- |
| androidx.activity:activity-compose | `1.11.0` | `1.12.0` |
| androidx.lifecycle:lifecycle-runtime-compose | `2.9.4` | `2.10.0` |
| androidx.lifecycle:lifecycle-viewmodel-ktx | `2.9.4` | `2.10.0` |
| androidx.lifecycle:lifecycle-viewmodel-compose | `2.9.4` | `2.10.0` |
| androidx.compose:compose-bom | `2025.11.00` | `2025.11.01` |
| [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` |
| [com.squareup.okhttp3:okhttp-brotli](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` |
| [com.squareup.okhttp3:logging-interceptor](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` |
| [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` |
| androidx.room:room-ktx | `2.8.3` | `2.8.4` |
| androidx.room:room-compiler | `2.8.3` | `2.8.4` |
| androidx.room:room-paging | `2.8.3` | `2.8.4` |
| androidx.room:room-runtime | `2.8.3` | `2.8.4` |
| androidx.room:room-testing | `2.8.3` | `2.8.4` |
| [com.google.devtools.ksp](https://github.com/google/ksp) | `2.3.2` | `2.3.3` |


Updates `androidx.activity:activity-compose` from 1.11.0 to 1.12.0

Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.9.4 to 2.10.0

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.4 to 2.10.0

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.4 to 2.10.0

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.4 to 2.10.0

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.4 to 2.10.0

Updates `androidx.compose:compose-bom` from 2025.11.00 to 2025.11.01

Updates `com.squareup.okhttp3:okhttp` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `com.squareup.okhttp3:mockwebserver` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `com.squareup.okhttp3:mockwebserver` from 5.3.0 to 5.3.2
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2)

Updates `androidx.room:room-ktx` from 2.8.3 to 2.8.4

Updates `androidx.room:room-compiler` from 2.8.3 to 2.8.4

Updates `androidx.room:room-paging` from 2.8.3 to 2.8.4

Updates `androidx.room:room-runtime` from 2.8.3 to 2.8.4

Updates `androidx.room:room-testing` from 2.8.3 to 2.8.4

Updates `androidx.room:room-compiler` from 2.8.3 to 2.8.4

Updates `androidx.room:room-paging` from 2.8.3 to 2.8.4

Updates `androidx.room:room-runtime` from 2.8.3 to 2.8.4

Updates `androidx.room:room-testing` from 2.8.3 to 2.8.4

Updates `com.google.devtools.ksp` from 2.3.2 to 2.3.3
- [Release notes](https://github.com/google/ksp/releases)
- [Commits](https://github.com/google/ksp/compare/2.3.2...2.3.3)

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.11.01
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp-brotli
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:logging-interceptor
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp-brotli
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:logging-interceptor
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-ktx
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.devtools.ksp
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 18:42:28 +01:00
Ricki Hirner
7c2dcf3d70 Comment out failing tests (#1836) 2025-11-24 16:00:53 +01:00
dependabot[bot]
66a34ebd9f [CI] Bump actions/checkout from 5 to 6 in the ci-actions group (#1837)
Bumps the ci-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 16:00:24 +01:00
Ricki Hirner
365364aa89 Update version to 4.5.6 2025-11-24 13:26:18 +01:00
Ricki Hirner
114543f4c5 Fetch translations from Transifex 2025-11-24 13:22:09 +01:00
Ricki Hirner
3bd3f56e1b Fix DeadObjectException handling in SyncManager and Syncer (#1834)
- Update SyncManager to handle LocalStorageException with DeadObjectException cause
- Refactor Syncer to catch all exceptions and handle specific cases
2025-11-24 13:14:40 +01:00
Ricki Hirner
5263172376 [Ktor] Add MustBeClosed annotation to buildKtor method (#1829)
Add @MustBeClosed annotation to buildKtor method

This commit adds the `@MustBeClosed` annotation to the `buildKtor` method in `HttpClientBuilder.kt` to indicate that the returned `HttpClient` instance must be closed by the caller. It also updates the test in `HttpClientBuilderTest.kt` to use the `use` function to ensure proper resource management.
2025-11-21 12:48:04 +01:00
Ricki Hirner
babd52cfb1 Bump version to 4.5.6-rc.1 2025-11-20 16:45:40 +01:00
Ricki Hirner
3d4d533b92 Update synctools (#1825)
* Update synctools

* Update commit ID
2025-11-20 16:42:40 +01:00
Ricki Hirner
76fc024ef6 Lower default minimum log level from INFO to FINE (#1827)
* Increase logging level

* Adjust log levels for visibility in non-verbose logs

- Change log level from FINER to FINE in StreamingFileDescriptor
- Update log level from FINER to FINE in AccountSettingsMigration8
- Add note about log levels in LogManager documentation

* Fix KDoc typo

* Update comment
2025-11-20 11:37:24 +01:00
Sunik Kupfer
794b4c1c7f DebugInfo: Support viewing jtx Board resources (#1818)
* Support viewing jtxBoard resources from debug info

* Correct value of EXTRA_LOCAL_RESOURCE_URI

* Correct comment

* Use the same intent for journals, notes, calendar and tasks

* State working task authorities explicitly

* Use edit action to not crash opentasks

* Use getViewIntentUriFor for jtx Board tasks

* Remove explicit tasks authority for jtx Board

* Remove explicit tasks authority for jtx Board

* Remove early return statement

* Dont handle jtxBoard tasks in LocalTask which is only for Dmfs tasks

* Add some kdoc to LocalTask and LocalJtxICalObject

* Use when with in list

* Add FLAG_GRANT_READ_URI_PERMISSION to the correct intent

* Correct line endings to from CRLF to LF
2025-11-20 09:33:24 +01:00
Ricki Hirner
aac6356722 Delete app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt
Remove empty file
2025-11-19 16:52:35 +01:00
Ricki Hirner
9bc46d4194 Update dependencies, including our libs (#1822) 2025-11-19 15:35:06 +01:00
Ricki Hirner
a3aac44775 Ignore failing test: testVerifySyncAlwaysPending_wrongBehaviour_android14 (#1824)
Comment out test
2025-11-19 15:14:41 +01:00
Arnau Mora
084ba3b630 Update dav4jvm to new okhttp package (#1786)
* Upgrade dav4jvm

* Exclude ktor from dav4jvm

* Fix imports and fix usages

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

* Fix imports for instrumented tests

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

* Fix imports

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

* Upgrade dav4jvm

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

* Do not exclude ktor in dav4jvm

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

* Upgrade dav4jvm

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-11-17 13:38:10 +01:00
Ricki Hirner
28dcf90775 Refactor default reminder builder to dedicated class + unit tests (#1815)
* [WIP] Move default reminder builder to dedicated class + unit tests

* Add tests

* Just turn off Conscrypt for now

* Fix library name
2025-11-17 10:34:36 +01:00
Ricki Hirner
70766affd9 [Ktor] Allow building a Ktor client (#1810)
* Add Ktor dependency

* Add buildKtor method

* Add test and deprecation notice

* KDoc
2025-11-17 09:38:08 +01:00
Ricki Hirner
d00292f421 Only use cert4android when needed (#1802)
* Refactor ClientCertKeyManager and HttpClientBuilder

- Add logging to `ClientCertKeyManager` for better error handling.
- Update `HttpClientBuilder` to conditionally use custom trust manager and hostname verifier based on `allowCustomCerts` flag.
- Rename `customCertsUI` to `allowCustomCerts` in build configuration.

* Update trust manager and hostname verifier selection logic

- Improve logging and error handling in `ClientCertKeyManager`

* App settings: hide certificate settings when custom certificates are not allowed

* Typo
2025-11-12 11:04:13 +01:00
dependabot[bot]
6b5c4f191a Bump the app-dependencies group with 2 updates (#1803)
Bumps the app-dependencies group with 2 updates: androidx.compose:compose-bom and [com.google.devtools.ksp](https://github.com/google/ksp).


Updates `androidx.compose:compose-bom` from 2025.10.01 to 2025.11.00

Updates `com.google.devtools.ksp` from 2.3.0 to 2.3.2
- [Release notes](https://github.com/google/ksp/releases)
- [Commits](https://github.com/google/ksp/compare/2.3.0...2.3.2)

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.11.00
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.google.devtools.ksp
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 17:28:43 +01:00
Sunik Kupfer
5c7b792e7f Support sync adapter pending sync indication on Android 14+ (#1676)
* Add AccountSettingsMigration21 to cancel pending address book syncs

* Add application context annotation

* Add log statement

* Increase account settings current version

* Add and update kdoc

* Call cancelSync via integration

* Optimize imports

* Update kdoc

* Updating log statement

* Also cancel calendar syncs

* Don't infer authority from account type

* Update kdoc

* Cancel only on Android 14+

* Cancel for all authorities and update kdoc

* Use cancelSync directly in migration

* Enable forever pending sync workaround by canceling sync adapter framework syncs on Android 14+

* Stop always returning false for pending sync state of sync adapter framework

* Cancel by request and empty bundle

* Cancel syncs for calendar, tasks, and contacts separately

* Minor edits to log statement and kdoc

* Add migration test; Update migration

* Log all extras instead of just upload flag

* Use lazy on syncFrameworkIntegration injection

* Multiple changes

- don't cancel address book accounts of all main accounts
- merge loops

* Add authority to log statement

* Replace complex state verification logic by status changed flow

* Cancel syncs account wide across all authorities

* Add some delay to allow dummy sync requests to be created

* Reduce wait until pending

* Drop Thread.sleep()

* Use a callback flow instead of mutable state flow

* Shorten first true filter

* Shorten remaining first true filter
2025-11-10 15:46:58 +01:00
Ricki Hirner
c9da496142 Bump version to 4.5.6-beta.1 2025-11-06 14:30:07 +01:00
Ricki Hirner
ee098c4a83 Explicitly integrate Conscrypt (#1796)
* Merge HttpClient and HttpClientBuilder

- Remove `HttpClient` class and replace with `OkHttpClient`
- Update all references to `HttpClient` to use `OkHttpClient`
- Add new `HttpClientBuilder` class for building `OkHttpClient` instances
- Update all builder usages to use the new `HttpClientBuilder` class

* KDoc

* Integrate Conscrypt for TLS

- Add Conscrypt dependency
- Initialize Conscrypt in HttpClientBuilder
- Create ConscryptIntegration utility

* KDoc

* Make object a class, better test

* Update cert4android to the latest version (doesn't bundle Conscrypt anymore)
2025-11-06 10:07:40 +01:00
Ricki Hirner
a8bd296520 Merge HttpClient and HttpClientBuilder (#1795)
* Merge HttpClient and HttpClientBuilder

- Remove `HttpClient` class and replace with `OkHttpClient`
- Update all references to `HttpClient` to use `OkHttpClient`
- Add new `HttpClientBuilder` class for building `OkHttpClient` instances
- Update all builder usages to use the new `HttpClientBuilder` class

* KDoc
2025-11-05 13:20:02 +01:00
Sunik Kupfer
0959624dee Update Calendars.OWNER_ACCOUNT when renaming an account (#1751)
* Fix typo

* Also set OWNER_ACCOUNT when updating calendar because renaming account

* Add test

* Update comment clarifying content values

* Assume calendar provider is present and drop null checks
2025-11-05 12:40:34 +01:00
Ricki Hirner
bd13d27e38 HttpClient: remove unnecessary close() (#1792)
* Remove unnecessary AutoCloseable implementations and client.close() calls

- Remove AutoCloseable from NextcloudLoginFlow and DavResourceFinder
- Remove client.close() calls in various classes and tests
- Update HttpClient to remove close() method

* Fix test

* Fix annotations / KDoc
2025-11-04 16:38:22 +01:00
dependabot[bot]
85548163ca Bump the app-dependencies group with 4 updates (#1790)
Bumps the app-dependencies group with 4 updates: [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp), [com.squareup.okhttp3:okhttp-brotli](https://github.com/square/okhttp), [com.squareup.okhttp3:logging-interceptor](https://github.com/square/okhttp) and [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp).


Updates `com.squareup.okhttp3:okhttp` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.2.1 to 5.3.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0)

---
updated-dependencies:
- dependency-name: com.squareup.okhttp3:okhttp
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp-brotli
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:logging-interceptor
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp-brotli
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:logging-interceptor
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 16:07:44 +01:00
Ricki Hirner
026750eca3 Bump version to 4.5.6-alpha.2 2025-11-01 21:56:47 +01:00
Ricki Hirner
d365a504e8 Refactor sequence handling in calendar sync (#1789)
* Refactor sequence handling in calendar sync

- Move sequence update logic to SequenceUpdater
- Update LocalCalendar to use new SequenceUpdater
- Remove redundant methods from LocalCalendar
- Update tests and dependencies

* Minor KDoc
2025-11-01 21:55:28 +01:00
Ricki Hirner
c64cb1e7ec Remove okhttp caching (bitfireAT/davx5#715)
* Remove okhttp caching
* Simplification
2025-11-01 12:38:26 +01:00
Ricki Hirner
837b5e5d50 Bump version to 4.5.6-alpha.1 2025-10-30 14:29:29 +01:00
Ricki Hirner
98aefc4fee Synchronize without Event data class (#1783)
* [WIP] Proof of concept for syncing without `Event` data class

* Replace AndroidEvent2 with EventsContract

* Update synctools, refactor upload logic in `CalendarSyncManager`

* KDoc

* Update UID immediately in `ContactsSyncManager`, `CalendarSyncManager`, and `TasksSyncManager`

- Remove `OnSuccessContext.uid` from `GeneratedResource`

* Minor changes

* Handle multiple events in a single iCalendar

- Rename `processVEvent` to `processICalendar`
- Add default alarm for non-full-day events again
- Prevent NPE on null flags (used for debug info)

* Fix tests
2025-10-30 14:28:05 +01:00
Ricki Hirner
a8c8a8d2e0 Refactor SEQUENCE and UID handling on successful uploads (#1785)
* Define interfaces

* [WIP] Refactor sequence and UID handling in event uploads

- Refactor `generateUpload` method to return `GeneratedResource`.
- Update `SyncManager` to handle `GeneratedResource`.
- Implement sequence and UID management in `CalendarSyncManager`.
- Remove redundant `prepareForUpload` method from `LocalEvent`.

* Refactor sequence handling in uploads

- Move UID validation to `DavUtils.isGoodFileBaseName`
- Update sequence directly in iCalendar for group-scheduled events
- Rename `resourceBaseName` to `suggestedBaseName` for clarity

* Refactor sequence / UID handling in contact uploads

- Update `LocalAddress` interface to include `updateUid` method
- Modify `ContactsSyncManager` to handle UID generation and update
- Remove redundant UID handling in `LocalContact` and `LocalGroup`
- Adjust code style settings for right margin

* Remove redundant UID update from `ContactsSyncManager` and `CalendarSyncManager`

* Implement UID handling in `TasksSyncManager` for uploads

* Update JtxSyncManager

- Update `JtxSyncManager.kt` to implement the `generateUpload` function.
- Update `LocalJtxICalObject.kt` to include the `updateUid` method for updating UIDs in the collection.

* Remove deprecated `prepareForUpload` method from `LocalResource`

- Remove `prepareForUpload` implementations from `LocalContact`, `LocalEvent`, `LocalGroup`, and `LocalTask`

* Rename `isGoodFileBaseName` to `isGoodFileName` in `DavUtils`

* Fix tests

* Move UID generation logic to `DavUtils.generateUidIfNecessary`

- Use `DavUtils.fileNameFromUid` for generating file names
- Update `ContactsSyncManager`, `CalendarSyncManager`, `TasksSyncManager`, and `JtxSyncManager` to use new utility methods

* Add tests for DavUtils

* Some tests

* Refactor onSuccessfulUpload

* Update KDoc

* Logging

* Remove unnecessary LocalEvent method

* KDoc
2025-10-29 17:57:34 +01:00
Ricki Hirner
b839cbfe7f Simplify LocalResource interface (#1784)
* Simplify LocalResource interface

- Remove generic parameter from LocalResource interface
- Update all implementations to reflect the change
- Adjust related test cases and exception handling

* Fix tests
2025-10-29 08:50:09 +01:00
Ricki Hirner
f0f9f58e49 Bump version to 4.5.5 2025-10-28 11:20:00 +01:00
Ricki Hirner
66f6e48e3b Fetch translations from Transifex 2025-10-28 11:18:54 +01:00
Ricki Hirner
d6feda1142 Fix OkHttp3 crash in release builds (bitfireAT/davx5#712)
- Add ProGuard rules to keep OkHttp3 IDN mapping classes
2025-10-28 11:12:51 +01:00
Ricki Hirner
05f058ab3f Version bump to 4.5.5-rc.1 2025-10-27 11:16:51 +01:00
Sunik Kupfer
4e4c0f5e31 Move debug info notification action to debug info screen button (#1730)
* Update view item on sync error string

* Remove view item action from notification

* Show button in debug info screen to jump to problematic event resource

* Move companion object to the end of activity class

* Add local resource dump to intent

* Add kdoc

* Add some comments for not yet implemented resources

* Don't export DebugInfoActivity

* Send intent instead of URI and launch from DebugInfoActivity

* Add option to view problematic contact

* Extract intent builder logic to another method

* Add option to view problematic contact

* Minor changes for readability

* Extract dump string creation to interface method

* Pass Uri instead of intent and create view local resource intent in DebugInfoActivity

* Use androids existing getContactLookupUri method

* Remove extra variable

* Remove obsolete val declaration

* Rename dump to summary

* Refactor code structure for local resource URI handling

* Update code structure to use getDebugSummary for local resource summaries

* Update exception handling in SyncNotificationManager

Change the catch block to handle all `Throwable` exceptions instead of just `OutOfMemoryError`. This ensures that any potential issues arising from providing information about the local resource are caught and ignored.

* Add "copy remote URL" action

* Use string resource

* Truncate contact, task, and event strings to 1000 characters

* Fix tests

* Minor changes

- Replace `ContactsContract.RawContacts` with `RawContacts` in `LocalContact.kt`
- Remove unnecessary newline in `LocalJtxICalObject.kt`

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-10-27 11:15:21 +01:00
Arnau Mora
0304d7168a Migrate to SecureTextField (#1191)
* Using `SecureTextField`

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

* `OutlinedSecureTextField` doesn't support `readOnly`

* fixed string conversions

* - Update AddWebdavMountScreen to use enabled instead of readOnly
- Ensure onKeyboardAction checks canContinue before proceeding
- Update UrlLogin and EmailLogin to ensure onKeyboardAction checks canContinue before proceeding
- Update InputDialogs to ensure confirmEnabled is checked before proceeding

* Use get() for deriving things from a mutable state

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-10-27 11:15:11 +01:00
Ricki Hirner
19458aa95c Delete .github/workflows/dependent-issues.yml
Obsoleted by https://github.blog/changelog/2025-08-21-dependencies-on-issues/
2025-10-26 12:23:31 +01:00
dependabot[bot]
4412617079 Bump the app-dependencies group across 1 directory with 7 updates (#1778)
Bumps the app-dependencies group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.work:work-runtime-ktx | `2.10.5` | `2.11.0` |
| androidx.work:work-testing | `2.10.5` | `2.11.0` |
| androidx.room:room-ktx | `2.8.2` | `2.8.3` |
| androidx.room:room-compiler | `2.8.2` | `2.8.3` |
| androidx.room:room-paging | `2.8.2` | `2.8.3` |
| androidx.room:room-runtime | `2.8.2` | `2.8.3` |
| androidx.room:room-testing | `2.8.2` | `2.8.3` |



Updates `androidx.work:work-runtime-ktx` from 2.10.5 to 2.11.0

Updates `androidx.work:work-testing` from 2.10.5 to 2.11.0

Updates `androidx.work:work-testing` from 2.10.5 to 2.11.0

Updates `androidx.room:room-ktx` from 2.8.2 to 2.8.3

Updates `androidx.room:room-compiler` from 2.8.2 to 2.8.3

Updates `androidx.room:room-paging` from 2.8.2 to 2.8.3

Updates `androidx.room:room-runtime` from 2.8.2 to 2.8.3

Updates `androidx.room:room-testing` from 2.8.2 to 2.8.3

Updates `androidx.room:room-compiler` from 2.8.2 to 2.8.3

Updates `androidx.room:room-paging` from 2.8.2 to 2.8.3

Updates `androidx.room:room-runtime` from 2.8.2 to 2.8.3

Updates `androidx.room:room-testing` from 2.8.2 to 2.8.3

---
updated-dependencies:
- dependency-name: androidx.work:work-runtime-ktx
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-ktx
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-26 11:04:22 +01:00
Ricki Hirner
76277dbfd5 Update Kotlin and KSP versions (#1776)
- Update Kotlin version to 2.2.21
- Update KSP version to 2.3.0
- Remove specific Kotlin and KSP dependencies from Dependabot ignore list
2025-10-26 10:20:57 +01:00
Ricki Hirner
b0b99de56b Update Compose BOM and use PrimaryTabRow (#1772)
- Update Compose BOM version to 2025.10.01
- Replace `TabRow` with `PrimaryTabRow`
2025-10-25 11:15:01 +02:00
Ricki Hirner
b33c4750bb Bump version to 4.5.5-beta.1 2025-10-18 09:15:58 +02:00
Sunik Kupfer
25b749dd1b Rename account on background thread (#1727)
* Change context to default dispatcher on updating automatic sync when renaming account

* Minor changes
- add worker thread annotations
- use injected defaultDispatcher

* Add tests

* Apply withContext with default dispatcher on the whole rename method
2025-10-18 09:13:26 +02:00
Ricki Hirner
39a0fe3f98 Update AboutLibraries and other dependencies (#1760)
Update dependencies and modify AboutActivity to use dynamic library loading

- Remove outdated aboutlibraries.json
- Update AboutActivity to dynamically load libraries using LocalContext
- Replace InvalidRemoteResourceException with InvalidICalendarException in sync managers
- Bump dependency versions for various libraries including mikepenz-aboutLibraries, okhttp, and unifiedpush
- Adjust build.gradle.kts and gradle/libs.versions.toml for new plugin and library versions
2025-10-18 09:12:41 +02:00
Arnau Mora
dd798f8380 Update implementation to match guidelines (#1747) 2025-10-15 11:20:30 +02:00
Sunik Kupfer
47d380de62 Fix unreachable code possibly causing foreign key constraint violation exception (#1740)
* Add test

* Fix unreachable code possibly causing foreign key constraint violation exception

* Make code easier to understand

* Add comments

* Split up tests and add mockk verify
2025-10-14 10:57:15 +02:00
dependabot[bot]
019d32a9b7 [CI] Bump github/codeql-action from 3 to 4 in the ci-actions group (#1749)
Bumps the ci-actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 3 to 4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 20:04:01 +02:00
Sunik Kupfer
0acabd9c80 Update http error message strings (#1745) 2025-10-13 10:41:00 +02:00
dependabot[bot]
aa23980a59 Bump the app-dependencies group across 1 directory with 13 updates (#1744)
* Bump the app-dependencies group across 1 directory with 13 updates

Bumps the app-dependencies group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.compose:compose-bom | `2025.09.01` | `2025.10.00` |
| [io.mockk:mockk](https://github.com/mockk/mockk) | `1.14.5` | `1.14.6` |
| [io.mockk:mockk-android](https://github.com/mockk/mockk) | `1.14.5` | `1.14.6` |
| [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| [com.squareup.okhttp3:okhttp-brotli](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| [com.squareup.okhttp3:logging-interceptor](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| androidx.room:room-ktx | `2.8.1` | `2.8.2` |
| androidx.room:room-compiler | `2.8.1` | `2.8.2` |
| androidx.room:room-paging | `2.8.1` | `2.8.2` |
| androidx.room:room-runtime | `2.8.1` | `2.8.2` |
| androidx.room:room-testing | `2.8.1` | `2.8.2` |
| org.unifiedpush.android:connector | `3.0.10` | `3.1.0` |



Updates `androidx.compose:compose-bom` from 2025.09.01 to 2025.10.00

Updates `io.mockk:mockk` from 1.14.5 to 1.14.6
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.6)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.6
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.6)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.6
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.6)

Updates `com.squareup.okhttp3:okhttp` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `androidx.room:room-ktx` from 2.8.1 to 2.8.2

Updates `androidx.room:room-compiler` from 2.8.1 to 2.8.2

Updates `androidx.room:room-paging` from 2.8.1 to 2.8.2

Updates `androidx.room:room-runtime` from 2.8.1 to 2.8.2

Updates `androidx.room:room-testing` from 2.8.1 to 2.8.2

Updates `androidx.room:room-compiler` from 2.8.1 to 2.8.2

Updates `androidx.room:room-paging` from 2.8.1 to 2.8.2

Updates `androidx.room:room-runtime` from 2.8.1 to 2.8.2

Updates `androidx.room:room-testing` from 2.8.1 to 2.8.2

Updates `org.unifiedpush.android:connector` from 3.0.10 to 3.1.0

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.10.00
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk
  dependency-version: 1.14.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp-brotli
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:logging-interceptor
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp-brotli
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:logging-interceptor
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-ktx
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: org.unifiedpush.android:connector
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Downgrade mockk version to 1.14.5

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-10-09 13:10:31 +02:00
Sunik Kupfer
5674d6b954 ExceptionInfoDialog: show explanation on http 405 and others (#1739)
* Show explanation on http 405

* Also show message for http 5xx

* Correct spelling
2025-10-09 12:05:31 +02:00
dependabot[bot]
df56c8628a [CI] Bump gradle/actions from 4 to 5 in the ci-actions group (#1736)
Bumps the ci-actions group with 1 update: [gradle/actions](https://github.com/gradle/actions).


Updates `gradle/actions` from 4 to 5
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v4...v5)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-06 17:08:39 +02:00
dependabot[bot]
af4ecd3a1d Bump the app-dependencies group with 12 updates (#1732)
Bumps the app-dependencies group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| androidx.work:work-runtime-ktx | `2.10.4` | `2.10.5` |
| androidx.work:work-testing | `2.10.4` | `2.10.5` |
| androidx.compose:compose-bom | `2025.09.00` | `2025.09.01` |
| [com.google.dagger:hilt-android](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| [com.google.dagger:hilt-android-compiler](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| [com.google.dagger:hilt-android-testing](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| [com.google.dagger.hilt.android](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| androidx.room:room-ktx | `2.8.0` | `2.8.1` |
| androidx.room:room-compiler | `2.8.0` | `2.8.1` |
| androidx.room:room-paging | `2.8.0` | `2.8.1` |
| androidx.room:room-runtime | `2.8.0` | `2.8.1` |
| androidx.room:room-testing | `2.8.0` | `2.8.1` |


Updates `androidx.work:work-runtime-ktx` from 2.10.4 to 2.10.5

Updates `androidx.work:work-testing` from 2.10.4 to 2.10.5

Updates `androidx.work:work-testing` from 2.10.4 to 2.10.5

Updates `androidx.compose:compose-bom` from 2025.09.00 to 2025.09.01

Updates `com.google.dagger:hilt-android` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-compiler` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-testing` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger.hilt.android` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-compiler` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-testing` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `androidx.room:room-ktx` from 2.8.0 to 2.8.1

Updates `androidx.room:room-compiler` from 2.8.0 to 2.8.1

Updates `androidx.room:room-paging` from 2.8.0 to 2.8.1

Updates `androidx.room:room-runtime` from 2.8.0 to 2.8.1

Updates `androidx.room:room-testing` from 2.8.0 to 2.8.1

Updates `androidx.room:room-compiler` from 2.8.0 to 2.8.1

Updates `androidx.room:room-paging` from 2.8.0 to 2.8.1

Updates `androidx.room:room-runtime` from 2.8.0 to 2.8.1

Updates `androidx.room:room-testing` from 2.8.0 to 2.8.1

Updates `com.google.dagger.hilt.android` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

---
updated-dependencies:
- dependency-name: androidx.work:work-runtime-ktx
  dependency-version: 2.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.09.01
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-ktx
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-29 22:15:39 +02:00
Arnau Mora
701e292ab5 Use IntentCompat for account fetching from intent extras (#1731) 2025-09-28 10:51:57 +02:00
Ricki Hirner
5da7d9e292 Add CODEOWNERS file (#1729) 2025-09-24 14:11:40 +02:00
Ricki Hirner
e6256764ac Bump version to 4.5.5-alpha.2 2025-09-24 14:00:30 +02:00
Ricki Hirner
e63f815416 Update synctools to work around eventStatus=null update problem (#1728)
Update synctools to work around eventStatus=null update problem; add synctools to Dependabot-ignore
2025-09-24 13:59:12 +02:00
Ricki Hirner
374dadfaaa Bump version to 4.5.5-alpha.1 2025-09-23 11:15:07 +02:00
Ricki Hirner
f6ef13f9fe Update synctools (new uses event builders/processors) and Kotlin/KSP (#1726) 2025-09-23 11:07:41 +02:00
dependabot[bot]
a88cfd2acf Bump the app-dependencies group with 4 updates (#1724)
Bumps the app-dependencies group with 4 updates: androidx.lifecycle:lifecycle-runtime-compose, androidx.lifecycle:lifecycle-viewmodel-ktx, androidx.lifecycle:lifecycle-viewmodel-compose and [com.google.guava:guava](https://github.com/google/guava).


Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.3 to 2.9.4

Updates `com.google.guava:guava` from 33.4.8-android to 33.5.0-android
- [Release notes](https://github.com/google/guava/releases)
- [Commits](https://github.com/google/guava/commits)

---
updated-dependencies:
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.guava:guava
  dependency-version: 33.5.0-android
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 18:40:14 +02:00
Michael Biebl
98fc946594 Remove duplicate lines in .gitignore (#1725) 2025-09-22 18:20:50 +02:00
Ricki Hirner
00523d9bc8 Fix state matching logic in AndroidSyncFrameworkTest (#1708)
* Fix state matching logic in AndroidSyncFrameworkTest

- Add `fullMatch` parameter to control whether all expected states must be present

* Ensure non-optional expected state matches actual state

* Remove unused rule / variable

* Adapt test

- Update `onStatusChanged` to override the interface method.
- Replace custom assertion with `assertTrue` for state comparison.
2025-09-16 10:42:11 +02:00
dependabot[bot]
53d338d03e Bump the app-dependencies group with 12 updates (#1714)
Bumps the app-dependencies group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| androidx.activity:activity-compose | `1.10.1` | `1.11.0` |
| androidx.hilt:hilt-compiler | `1.2.0` | `1.3.0` |
| androidx.hilt:hilt-navigation-compose | `1.2.0` | `1.3.0` |
| androidx.hilt:hilt-work | `1.2.0` | `1.3.0` |
| androidx.work:work-runtime-ktx | `2.10.3` | `2.10.4` |
| androidx.work:work-testing | `2.10.3` | `2.10.4` |
| androidx.compose:compose-bom | `2025.08.01` | `2025.09.00` |
| androidx.room:room-ktx | `2.7.2` | `2.8.0` |
| androidx.room:room-compiler | `2.7.2` | `2.8.0` |
| androidx.room:room-paging | `2.7.2` | `2.8.0` |
| androidx.room:room-runtime | `2.7.2` | `2.8.0` |
| androidx.room:room-testing | `2.7.2` | `2.8.0` |


Updates `androidx.activity:activity-compose` from 1.10.1 to 1.11.0

Updates `androidx.hilt:hilt-compiler` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-navigation-compose` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-work` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-navigation-compose` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-work` from 1.2.0 to 1.3.0

Updates `androidx.work:work-runtime-ktx` from 2.10.3 to 2.10.4

Updates `androidx.work:work-testing` from 2.10.3 to 2.10.4

Updates `androidx.work:work-testing` from 2.10.3 to 2.10.4

Updates `androidx.compose:compose-bom` from 2025.08.01 to 2025.09.00

Updates `androidx.room:room-ktx` from 2.7.2 to 2.8.0

Updates `androidx.room:room-compiler` from 2.7.2 to 2.8.0

Updates `androidx.room:room-paging` from 2.7.2 to 2.8.0

Updates `androidx.room:room-runtime` from 2.7.2 to 2.8.0

Updates `androidx.room:room-testing` from 2.7.2 to 2.8.0

Updates `androidx.room:room-compiler` from 2.7.2 to 2.8.0

Updates `androidx.room:room-paging` from 2.7.2 to 2.8.0

Updates `androidx.room:room-runtime` from 2.7.2 to 2.8.0

Updates `androidx.room:room-testing` from 2.7.2 to 2.8.0

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-compiler
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-navigation-compose
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-work
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-navigation-compose
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-work
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-runtime-ktx
  dependency-version: 2.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.09.00
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-ktx
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-12 10:35:17 +02:00
Ricki Hirner
0424999225 Update dav4jvm (#1709)
- Replace `code` with `statusCode` in multiple files to align with the updated library
- Add `dav4jvm` to Dependabot ignores
2025-09-12 10:25:26 +02:00
Ricki Hirner
fa09a0560f Use SensitiveString for passwords (#1692)
* Use SensitiveString for passwords to prevent them from being logged by `toString()`

* Add test

* Fix other tests

* Credentials: equals / hashCode not needed anymore

* Add tests for equals
2025-09-10 10:31:24 +02:00
Ricki Hirner
f4aa55d482 Version bump to 4.5.4 2025-09-09 11:38:10 +02:00
Ricki Hirner
1bffd5efe1 Version bump to 4.5.4-rc.3 2025-09-06 13:14:48 +02:00
Ricki Hirner
4850f2a5a5 Update synctools to fix #1701 (#1702) 2025-09-06 13:01:56 +02:00
Ricki Hirner
53f38ce2ec Rename Dependabot dependency group 2025-09-05 11:27:06 +02:00
Ricki Hirner
7cf6e30577 Bump version to 4.5.4-rc.2 2025-09-05 10:31:21 +02:00
Ricki Hirner
cec77c33cb Fix KotlinNotImplementedError in LocalGroup update method (#1696)
- Remove scheduleTag assignment in LocalGroup update method
- Refactor LocalGroupTest to use @Before and @After annotations
- Add new test case for update method in LocalGroupTest
2025-09-05 10:27:10 +02:00
dependabot[bot]
6c98e6d501 Bump the lib-dependencies group across 1 directory with 9 updates (#1695)
Bumps the lib-dependencies group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.lifecycle:lifecycle-runtime-compose | `2.9.2` | `2.9.3` |
| androidx.lifecycle:lifecycle-viewmodel-ktx | `2.9.2` | `2.9.3` |
| androidx.lifecycle:lifecycle-viewmodel-compose | `2.9.2` | `2.9.3` |
| androidx.compose:compose-bom | `2025.08.00` | `2025.08.01` |
| [com.google.dagger:hilt-android](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| [com.google.dagger:hilt-android-compiler](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| [com.google.dagger:hilt-android-testing](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| [com.google.dagger.hilt.android](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| com.android.application | `8.12.1` | `8.13.0` |



Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.2 to 2.9.3

Updates `androidx.compose:compose-bom` from 2025.08.00 to 2025.08.01

Updates `com.google.dagger:hilt-android` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-compiler` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-testing` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger.hilt.android` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-compiler` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-testing` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.android.application` from 8.12.1 to 8.13.0

Updates `com.google.dagger.hilt.android` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

---
updated-dependencies:
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.08.01
  dependency-type: direct:production
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.android.application
  dependency-version: 8.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 10:25:31 +02:00
Ricki Hirner
a7cd1cd49f Configure Dependabot for Gradle dependencies (#1680)
* Configure Dependabot for Gradle dependencies

Add Gradle dependency management configuration to Dependabot.

* Update dependabot.yml to ignore specific Kotlin and KSP dependencies
2025-09-05 09:26:30 +02:00
Ricki Hirner
0cc84dfd01 Update synctools (#1690) 2025-09-03 16:47:40 +02:00
Ricki Hirner
87239daaf6 Fix invalid translation 2025-09-03 15:53:43 +02:00
Ricki Hirner
81ceb57842 Version bump to 4.5.4-rc.1 2025-09-03 15:41:31 +02:00
Ricki Hirner
cd0b0c0804 Fetch translations from Transifex 2025-09-03 15:41:12 +02:00
Sunik Kupfer
48cbd4a05d Disable pending sync indicator on Android 14+ (#1689)
* Disable pending sync indicator on Android 14+

* Disable forever pending sync workaround on Android 14+
2025-09-03 15:32:29 +02:00
Ricki Hirner
beccc7a0d4 Version bump to 4.5.4-beta.1 2025-09-01 11:09:52 +02:00
Sunik Kupfer
2b629c8b18 Add comments on foreign key constraint enforcement (#1675)
* Add comments on foreign key constraint enforcement

* Fix grammar

* Add foreign key constraint comment to AppDatabase

* Update kdoc with better description
2025-08-28 11:47:49 +02:00
Arnau Mora
cd725479cd Handle null cases for Events.DIRTY (#1663)
* Handled nullity of `Calendar.dirty`

* Fixed ids

* Use multiline SQL queries

* Fix kdoc links

* Improve kdoc

* Fix capitalization

* Simplify kdoc

* Improved `LocalCalendarTest`
2025-08-28 11:25:57 +02:00
Ricki Hirner
44666d2138 Add groups section to dependabot configuration (#1677)
* Add groups section to dependabot configuration

* Fix patterns syntax in dependabot.yml
2025-08-27 15:56:34 +02:00
dependabot[bot]
8e67db7d54 [CI] Bump actions/setup-java from 4 to 5 (#1672)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 10:10:21 +02:00
Sunik Kupfer
a58e3b9036 Don't disable sync-ability of address books when set to manual only (#1662)
Only disable sync on content changes; but leave syncable
2025-08-25 10:15:56 +02:00
Ricki Hirner
d63918ff42 Update dependencies to latest versions (including AGP) (#1665) 2025-08-19 16:45:27 +02:00
dependabot[bot]
f21c3de94a [CI] Bump actions/checkout from 4 to 5 (#1664)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 13:54:15 +02:00
Ricki Hirner
24d4ba65e5 Fix NPE when Event._ID is larger than Integer.MAX_VALUE (#1661)
* Add test that reproduces NPE

* Fix NPE when Event ID is larger than Integer.MAX_VALUE
2025-08-14 17:19:04 +02:00
Arnau Mora
ae96f1ffbb Using new suggested icons for tabs (#1657) 2025-08-14 10:20:08 +02:00
Arnau Mora
a08ecae635 Change "Account doesn't exist" toast message to "Account has been removed" for deleting accounts (#1650)
* Finish activity after deleting

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

* Added proper toast for when the account is deleted

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

* Simplify logic

* Missing fix

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-13 08:55:26 +02:00
Ricki Hirner
eb4224780a Update to Gradle 9.0 (#1647) 2025-08-07 09:30:37 +02:00
Ricki Hirner
0240e67dab Bump version to 4.5.4-alpha.1 2025-08-06 16:42:10 +02:00
Ricki Hirner
0ccd9d5eb3 Update synctools for events as Entity and EventAndExceptions (#1605)
* [WIP] Refactor LocalResource interface and implementations to use immutable properties and add deleteLocal method

* Use `Optional` for `fileName` in `clearDirty` methods, update syncManagers

* Update synctools

* Refactor LocalCalendar to use AndroidRecurringCalendar for event operations

* Use AndroidCalendar.findEvent

* Update SyncManager to process fileName, eTag, scheduleTag and flags

* SyncManager: make ETag and Schedule-Tag processing more understandable

* Make upload handling more clear

* Update synctools
2025-08-06 15:38:38 +02:00
Sunik Kupfer
438f967152 [Sync Framework] Android 14+: Cancel forever pending address book account syncs (#1643)
* Also cancel sync for address books accounts

* Cancel known account directly

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-08-06 12:07:49 +02:00
Ricki Hirner
a093238864 Make "sync pending" work in AccountsScreen and AccountScreen (#1615)
* Add sync pending check for Android 14+

* Update sync pending UI logic to use selected authorities only

* Fix isSyncPending not handling multiple dataTypes

* Extract the accounts flow map to boolean flow logic

* Rename method

* Enhance kdoc

* Pass only one authority for pending check

* Update kdoc

* Update kdoc

* Update kdoc

* Fix whitespace

* Rename authority method to currentAuthority

* Update kdoc

---------

Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2025-08-06 12:04:24 +02:00
Arnau Mora
293daf1e82 Fix bottom bar color on custom tabs (#1640)
* Fix bottom bar color on custom tabs

* Removed themind

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-06 11:45:29 +02:00
Sunik Kupfer
3b50747ce9 Rename CollectionListRefresher to CollectionsWithoutHomeSetRefresher (#1644)
* CollectionListRefresher to CollectionsWithoutHomeSetRefresher

* Adapt tests

* Unify class/method naming

* Use "without homeset" wording when applicable

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-08-06 10:45:43 +02:00
Arnau Mora
51d6ed279a Fetch translations from Transifex (#1638) 2025-08-04 11:50:15 +02:00
Arnau Mora
2c6842ac0c Version bump to 4.5.3 (#1637)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-04 11:38:40 +02:00
Ricki Hirner
0e6644305a Bump version to 4.5.3-rc.2 2025-08-01 10:49:28 +02:00
Ricki Hirner
10e3b0a723 Update dependencies, including AGP (#1632) 2025-08-01 10:49:00 +02:00
Arnau Mora
2f45b705b3 Replaced Webcal tab icon in collections list (#1628)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-01 10:43:09 +02:00
Ricki Hirner
be6c3311d7 WebDAV: remove notifications, timeout logic (#1630)
* Refactor openDocument operation to use OsConstants for mode parsing

* RandomAccessCallbackWrapper: refactor so that it's only purpose is to avoid the memory leak

* Use main looper instead of a new thread per RandomAccessCallback

* Remove WebDAV access notification

* Remove nsk90-kstatemachine dependency

* Simplify fileDescriptor() method

* Use dedicated I/O thread again; use Kotlin `copyTo` for copying
2025-08-01 10:38:24 +02:00
Ricki Hirner
755863778b Bump version to 4.5.3-rc.1 2025-07-30 10:49:15 +02:00
Ricki Hirner
0e81866d3a Fix ETag update logic in QueryChildDocumentsOperation (#1626) 2025-07-30 10:48:39 +02:00
Arnau Mora
93a256ee75 Icons next to CalDAV/CardDAV tab titles (#1599)
* Icons next to CalDAV/CardDAV tab titles

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

* Use shared element transitions

* Switch to `sharedBounds` to allow font size changes

* Update app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Minor changes

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-30 10:30:16 +02:00
Arnau Mora
61e9d60b7c Correctly handle SecurityException for acquireContentProvider (#1622)
* Handle `SecurityException` for `acquireContentProvider`

* Added optional `throwOnMissingPermissions` arg to `acquireContentProvider`

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

* Set `throwOnMissingPermissions` to `true`

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

* Adapt comments, remove now unnecessary try/catch in AccountSettingsMigration20

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-30 10:07:54 +02:00
Ricki Hirner
dc9fb7b608 AndroidSyncFrameworkTest: allow optional states (#1625)
* Update sync state verification to allow optional states

* Refactor AndroidSyncFrameworkTest to use stateEquals for comparison
2025-07-30 09:42:16 +02:00
Ricki Hirner
44b52f65a2 [WebDAV] Implement command pattern, streamline lifecycle, remove WebdavScope (#1617)
* Remove WebdavScope as it is no longer needed

* [WIP] DavDocumentsProviderImpl

* [WIP] Move DavDocumentsProvider to BaseDavDocumentsProvider and implementation to DavDocumentsProvider

* Adapt tests and DI

* [WIP] Implement Command pattern

* Finish Command pattern, add deprecation notices

* Unify DavDocumentsProvider with wrapper again

* Get rid of DavDocumentsActor

* Add notes about lifecycle, remove shutdown
2025-07-29 10:17:05 +02:00
Sunik Kupfer
e13c140554 Extract refreshHomesetsAndTheirCollections to HomeSetRefresher (#1606)
* Extract refreshHomesetsAndTheirCollections to HomeSetRefresher

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Minor changes

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-28 09:59:40 +02:00
Ricki Hirner
cdb50205f4 Don't warn on newer Commons versions 2025-07-24 18:12:06 +02:00
Sunik Kupfer
2ba4a2a510 Version bump to 4.5.3-alpha.1 2025-07-24 14:07:12 +02:00
Sunik Kupfer
38b2377760 Follow redirects when adding webdav mount (#1582)
* Follow redirects when adding webdav mount

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update dav4jvm version

* Remove unused null

* Fix tests

* Add Kdoc

* Add Kdoc

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-24 14:02:50 +02:00
Sunik Kupfer
10f6356a6e Extract refreshPrincipals() to PrincipalsRefresher (#1607)
* Extract refreshPrincipals to PrincipalsRefresher

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Make method public

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-07-24 13:49:23 +02:00
Ricki Hirner
df4b6d3fbc Use early entry point for sync adapter services (#1610) 2025-07-24 13:19:56 +02:00
Ricki Hirner
dab948730e Choose real or fake (for tests) SyncAdapter over DI (#1608)
* Choose real or fake (for tests) SyncAdapter over DI

* Minor changes

* Rename SyncAdapterServicesTest.kt to RealSyncAdapterTest.kt

* Group sync adapter / sync framework classes into new package

* Cache SyncAdapter in SyncAdapterServices

* Add documentation to SyncAdapter interface and rename RealSyncAdapterTest to SyncAdapterImplTest
2025-07-24 12:19:54 +02:00
Sunik Kupfer
288583bfad Extract discoverHomesets() to ServiceRefresher (#1604)
* Extract discoverHomesets to ServiceRefresher

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move hiltRule to top; Add space

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Log every request with method and path

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-07-24 11:33:48 +02:00
Sunik Kupfer
98c0b0c36a [Sync framework] Fix sync always pending on Android 14+ (#1463)
* Android 14 and 15 workaround: tell the sync framework to cancel any pending syncs

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add test which documents wrong pending sync check behaviour

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Exclude android 13 and below

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel only own sync request

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel only after enqueuing sync worker

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move test to AndroidSyncFrameworkTest

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Reset master sync state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove limited parallelism and increase test timeout

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename test method

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add assert message

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add sdk suppress annotation

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use runBlocking to be able to catch the timeout exception

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Extract pending sync cancellation to method

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Enhance sync framework tests for Android versions 9 to 16, verifying correct and incorrect pending states

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add tests for sync always pending behavior on Android 10 and 11

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove obsolete unmockkAll call

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Make tests a bit more reliable

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Extract cancelSync method call to utils to stub the call more easily

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove some unnecessary calls and update stub

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update expected states lists

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move cancelSyncInSyncFramework to SyncFrameworkIntegration

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Pass the whole sync extras bundle when cancelling sync

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* [WIP] Initialize pending sync state reporting wrong behaviour

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Optimize SyncAdapterServicesTest

* Remove unused property

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Reset master sync state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Revert "Reset master sync state"

This reverts commit 4bfe73a25a.

* Revert "Remove unused property"

This reverts commit 7c0fdbf392.

* Reapply "Reset master sync state"

This reverts commit 5f7f0f9bce.

* Reapply "Remove unused property"

This reverts commit f1d5009f8a.

* Increase timeout to 2 min

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* [WIP] Optimize tests

* Optimize sync framework tests

* SyncAdapterServices FakeSyncAdapter: support interrupting

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-23 13:03:02 +02:00
Sunik Kupfer
ed7a477d3f [UI] Use "(optional)" in labels to indicate optional textfields (#1571)
* Clarify optional fields in UI

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add icons to add webdav mount screen

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add title to group mount point address and name

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use assistant composable

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-22 09:20:06 +02:00
Ricki Hirner
b0609fafb2 Update synctools for AndroidEvent2 (#1601)
* [WIP] Update synctools

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Update cert4android to get 16 kB page size support over Conscrypt 2.5.3 (#1581)

* Move SyncState to resource package because it's not in the database (#1585)

* Update dependencies, including dav4jvm that updates okhttp to 5.x (#1593)

* Update dependencies, including dav4jvm that updates okhttp to 5.x

* Update mockk and okhttp

* Bump version to 4.5.2-beta.1

* Move Insert/update to DAO (#1587)

* Move homeset insert/update logic from repository to DAO; add thread-safety test

* Rename insertOrUpdateByUrlRememberSync

* Fix exceptions being fetched as `Parcelable` instead of `Serializable` in `DebugInfoActivity` (#1597)

Typo: replace `getParcelableExtra` with `getSerializableExtra`

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

* Bump version to 4.5.2

* Fetch translations from Transifex

* Add documentation and handle missing event in LocalEvent

* Minor changes

* Rename `event` to `getCachedEvent()` to make it more clear what it does

* Update SEQUENCE after successful event upload more explicitly

* Update sequence after successful calendar event upload

* Remove deprecated add() method from LocalResource

* Update KDoc

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2025-07-22 09:15:10 +02:00
Ricki Hirner
94a85833bc Bump version to 4.5.2.1 2025-07-21 18:13:31 +02:00
Ricki Hirner
4c5c8c3ed0 Don't disable AndroidX startup completely (#1603)
* Add test

* Update AndroidManifest.xml to configure Hilt/WorkManager integration and remove default WorkManagerInitializer
2025-07-21 18:09:37 +02:00
Ricki Hirner
4685ab6d0c Revert "Update synctools" (#1600)
Revert "Update synctools (#1579)"

This reverts commit 62a0ba3520.
2025-07-21 11:57:34 +02:00
Ricki Hirner
62a0ba3520 Update synctools (#1579)
* [WIP] Update synctools

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Update cert4android to get 16 kB page size support over Conscrypt 2.5.3 (#1581)

* Move SyncState to resource package because it's not in the database (#1585)

* Update dependencies, including dav4jvm that updates okhttp to 5.x (#1593)

* Update dependencies, including dav4jvm that updates okhttp to 5.x

* Update mockk and okhttp

* Bump version to 4.5.2-beta.1

* Move Insert/update to DAO (#1587)

* Move homeset insert/update logic from repository to DAO; add thread-safety test

* Rename insertOrUpdateByUrlRememberSync

* Fix exceptions being fetched as `Parcelable` instead of `Serializable` in `DebugInfoActivity` (#1597)

Typo: replace `getParcelableExtra` with `getSerializableExtra`

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

* Bump version to 4.5.2

* Fetch translations from Transifex

* Add documentation and handle missing event in LocalEvent

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2025-07-21 11:54:20 +02:00
Ricki Hirner
71f3558b4b Fetch translations from Transifex 2025-07-21 10:50:15 +02:00
Ricki Hirner
22d933096f Bump version to 4.5.2 2025-07-21 10:24:22 +02:00
Arnau Mora
666b707854 Fix exceptions being fetched as Parcelable instead of Serializable in DebugInfoActivity (#1597)
Typo: replace `getParcelableExtra` with `getSerializableExtra`

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-07-21 10:03:19 +02:00
Ricki Hirner
39f6b82926 Move Insert/update to DAO (#1587)
* Move homeset insert/update logic from repository to DAO; add thread-safety test

* Rename insertOrUpdateByUrlRememberSync
2025-07-21 09:28:32 +02:00
Sunik Kupfer
b02fd23f0a Bump version to 4.5.2-beta.1 2025-07-17 11:26:40 +02:00
Ricki Hirner
ca56380c29 Update dependencies, including dav4jvm that updates okhttp to 5.x (#1593)
* Update dependencies, including dav4jvm that updates okhttp to 5.x

* Update mockk and okhttp
2025-07-17 11:06:04 +02:00
Ricki Hirner
ba9eb1446b Move SyncState to resource package because it's not in the database (#1585) 2025-07-16 12:06:34 +02:00
Ricki Hirner
055599c74f Update cert4android to get 16 kB page size support over Conscrypt 2.5.3 (#1581) 2025-07-15 09:50:39 +02:00
Ricki Hirner
62db3da579 Update Android Gradle Plugin to 8.11.1 and AboutLibraries to 12.2.4 2025-07-14 10:58:22 +02:00
Ricki Hirner
76d8d5acbf Sync davx5 / davx5-ose repos (#1575)
* Add custom certs UI build config field and use it in HttpClient; sync CollectionDao

* Backport max accounts setting
2025-07-10 16:49:07 +02:00
Ricki Hirner
dd294a4b03 Move external URIs from Constants to ExternalUris object (#1574)
* [WIP] Move external URIs from Constants to ExternalUris object

* Update external URIs to use class names or screen name for stat params
2025-07-10 16:14:09 +02:00
Sunik Kupfer
0efe6a7b9b Bump version to 4.5.2-alpha.1
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-07-10 11:49:25 +02:00
Ricki Hirner
405b7abb39 [synctools] Use EventReader / EventWriter (#1573)
* Update synctools. Use Writer instead of ByteArrayOutputStream

* Update synctools
2025-07-10 10:57:45 +02:00
Ricki Hirner
4e2640ca01 Synctools: AndroidEvent companion object moved (#1572)
* Update synctools

* Refactor LocalCalendar to use Hilt

* Update synctools
2025-07-09 16:35:27 +02:00
Sunik Kupfer
904c8ba29b [Sync framework] Disable contacts content change triggered syncs if sync interval set to manual only (#1569)
* Fix lint error

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Show manual sync interval setting in UI

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Disable contacts content change triggered syncs if set to manual; update kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comments and kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Automatically close provider

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Explicitly handle special case

* Rename updateAutomaticSync to updateSyncFrameworkSetting; adjust comments

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-09 11:13:54 +02:00
Michael Biebl
62dc73c2a0 Use system defined line separator (#1562)
This fixes the test suite on Windows.
Windows uses CR LF, in contrast to Linux which uses LF. So do not
hard-code the value.
2025-07-07 17:42:24 +02:00
Ricki Hirner
58344099f7 AndroidCalendar refactoring (#1560)
* [WIP] Refactor calendar sync manager to use synctools library

* [WIP] Update synctools

* [WIP] Tests

* Remove test logger module and update calendar color methods

* Fix migrations

* Update libs.versions.toml
2025-07-03 22:12:21 +02:00
Ricki Hirner
b62c7eff0b Update to synctools version that uses explicit parsing / splitting (#1554)
* Update to synctools version that uses explicit parsing / splitting

* Update synctools
2025-07-02 15:33:13 +02:00
Ricki Hirner
12cedd4010 Fetch translations from Transifex 2025-07-02 14:30:57 +02:00
Ricki Hirner
3a0221c749 Update version to 4.5.1 2025-07-02 14:30:07 +02:00
Ricki Hirner
f78e7868e8 Revert "Update to synctools version that uses explicit parsing / splitting (#1554)"
This reverts commit 5dbaedfa60.

Reason: we don't want it in 4.5.1 yet
2025-07-02 14:28:57 +02:00
Ricki Hirner
5dbaedfa60 Update to synctools version that uses explicit parsing / splitting (#1554)
* Update to synctools version that uses explicit parsing / splitting

* Update synctools
2025-07-01 21:52:20 +02:00
Ricki Hirner
6187f92efd Bump version to 4.5.1-rc.2 2025-06-29 16:33:51 +02:00
Ricki Hirner
82ccf6a2f9 Update workflows to use build cache; don't reduce error level for configuration cache problems anymore 2025-06-29 16:23:56 +02:00
Ricki Hirner
0f9c5027d4 Fetch translations from Transifex 2025-06-29 15:42:40 +02:00
Ricki Hirner
7b76df3e70 Bump version to 4.5.1-rc.1 2025-06-29 15:41:36 +02:00
Ricki Hirner
80cfe1013d [CI] Don't generate a new discussion thread for every release
Updates will be posted in major release thread manually
2025-06-29 15:28:23 +02:00
Ricki Hirner
3e3c346019 Update dav4jvm 2025-06-29 14:58:12 +02:00
Ricki Hirner
1773dff8a4 Update synctools for R8 rules 2025-06-27 21:54:14 +02:00
Ricki Hirner
604b0aab98 Bump version to 4.5.1-alpha.1 2025-06-27 17:44:04 +02:00
Ricki Hirner
35cffa603b LocalCalendar: don't subclass AndroidCalendar (#1552)
* [WIP] Refactor LocalEvent to delegate to AndroidEvent

* Move tests

* Use test rules from synctools

* Add null check for content provider client in JtxSyncManagerTest

* Update dependencies, move OkhttpClientTest

* Refactor LocalCalendar to wrap AndroidCalendar

* Update bitfire-synctools to 1a613d5d3c
2025-06-27 17:43:14 +02:00
Ricki Hirner
89c3eacd36 LocalEvent: don't subclass AndroidEvent (#1551)
* [WIP] Refactor LocalEvent to delegate to AndroidEvent

* Move tests

* Use test rules from synctools

* Add null check for content provider client in JtxSyncManagerTest

* Update dependencies, move OkhttpClientTest
2025-06-27 17:06:41 +02:00
Ricki Hirner
4246ed65ac OAuth: Synchronize access token generation (#1547)
* OAuthInterceptor: synchronize refreshing of access token

* Remove sensitive logging

* [WIP] Logging

* [Google] Support custom client ID on re-authorization

* Statically synchronize acquisition of access token

* [WIP] OAuth: use callbacks for reading/writing AuthState

* Fix DavResourceFinderTest

* Move Credentials class to settings package; KDoc

* Simplify reauthorization
2025-06-27 11:24:02 +02:00
Ricki Hirner
789e7f3045 Set iCalendar PRODID in Constants and use it in respective classes (#1550)
* Set iCalendar PRODID in Constants and use it in respective classes

* Update bitfire-synctools
2025-06-26 15:38:37 +02:00
Michael Biebl
66f99f7362 Update README.md after switch to synctools (#1549) 2025-06-26 12:50:27 +02:00
Sunik Kupfer
90b04ddbdc [Google] Remove warning box on login screen (#1548)
* Remove the google login warning card

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove unused uri handler variable

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-26 12:47:53 +02:00
Ricki Hirner
a7f8ea8a48 Don't subclass AndroidEvent / AndroidCalendar populate / build methods anymore (#1544)
* Fix tests

* Update synctools; use AndroidCalendar SyncState

* Update synctools; move companion objects to end of class declarations
2025-06-25 14:17:58 +02:00
Sunik Kupfer
42cd8d8631 Update sync progress bar when pending in SAF (#1445)
* Provide flow to check if sync of account and authority is pending in sync framework

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Show if sync is pending in sync framework

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Show if sync is pending in sync framework

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel any pending SAF syncs on sync request

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync only after having enqueued our worker sync

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Improve accuracy by also checking isSyncActive

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove log statements

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Only query pending state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync only on android 14 and 15

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync with authority

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Cancel sync adapter sync by using sync request instead of canceling directly

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Include android 16

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Include all versions after Android 14

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add test which documents wrong pending sync check behaviour

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Revert "Add test which documents wrong pending sync check behaviour"

This reverts commit 8c538149ff2cb032d6355232c1736e103dcc9a18.

* Drop Android 14+ always pending sync work around

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Differentiate better between enqueued and pending syncs

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Refactor sync pending check to use flow for address book accounts

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Always return false for sync pending state check on Android 14+

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Refactor sync pending check to use flow for address book accounts

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add comments

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Shorten variable name

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comments and variable name

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remvoe obsolete call and add argument names as comments

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove sync active check from listener

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-25 13:54:44 +02:00
Ricki Hirner
a26847cf10 Update AGP, Kotlin, dependencies 2025-06-24 23:00:19 +02:00
Ricki Hirner
0e6c26aec6 Update synctools and use unified BatchOperation (#1543)
* Update synctools and use unified BatchOperation

* Fix tests
2025-06-24 16:26:40 +02:00
Arnau Mora
2204027993 Improve IME integration on forms (#1504)
* Fix focus accessibility issues

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

* Got rid of focus options. Improved IME integration

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

* Remove custom focus requesters

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

* Move url text to top of fields

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

* Add focus requester again

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

* Moved text

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-24 11:36:07 +02:00
Arnau Mora
e7189d66b0 Link to Managed DAVx5 in navigation drawer (bitfireAT/davx5#633)
Added managed drawer entry
2025-06-24 09:43:48 +02:00
Ricki Hirner
c517647819 Update dependencies 2025-06-24 09:40:21 +02:00
Ricki Hirner
0780b226ff Add F-Droid changelog 2025-06-23 13:20:54 +02:00
Ricki Hirner
8d9a417753 Use Android 14 for instrumented tests again (#1535) 2025-06-23 10:51:07 +02:00
Ricki Hirner
008c314b80 Version bump to 4.5 2025-06-23 09:34:10 +02:00
Ricki Hirner
f0019c54b1 Fetch translations from Transifex 2025-06-23 09:33:35 +02:00
Ricki Hirner
51ad6ee00a Intro / battery optimizations warning: remove Samsung; change wording from "probably" to "may" (#1532)
* Intro / battery optimizations warning: remove Samsung; change wording from "probably" to "may"

* Remove Huawei from list
2025-06-20 12:15:43 +02:00
Ricki Hirner
dd453a7837 Update version to 4.4.12-rc.1 2025-06-16 13:17:46 +02:00
Ricki Hirner
f91c968eb8 Update synctools, unify local storage exception handling 2025-06-16 13:16:18 +02:00
Ricki Hirner
968a43f9cc Support OAuth re-authorization (#1521)
* [WIP] Add OAuth re-authorization flow

* Refactor authorization contract to OAuthIntegration

* Add OAuth re-authentication UI and logic

* Update KDoc and UI strings
2025-06-12 09:37:59 +02:00
Sunik Kupfer
ac965b411b Delete local collection if missing remotely at delete request (#1512)
* Inject IO dispatcher

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Delete local collection on delete request when not remotely present anymore

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-11 11:13:35 +02:00
Michael Biebl
1608384418 Redact Authorization header from HTTP requests (#1520)
* Redact Authorization header from HTTP requests

https://github.com/square/okhttp/blob/master/okhttp-logging-interceptor/README.md

https://github.com/bitfireAT/davx5-ose/discussions/1438

* Use constant HttpHeaders.AUTHORIZATION from com.google.common.net.HttpHeaders

https://guava.dev/releases/19.0/api/docs/com/google/common/net/HttpHeaders.html#AUTHORIZATION

* Redact Cookie headers as well
2025-06-10 15:13:28 +02:00
Ricki Hirner
f25c22eba5 Replace ical4android and vcard4android with synctools (#1519) 2025-06-10 13:32:46 +02:00
Ricki Hirner
b1f742fb3a Update dependencies to latest versions 2025-06-05 20:10:09 +02:00
Ricki Hirner
47c8a0589d Bump version to 4.4.12-alpha.1 2025-06-05 20:08:16 +02:00
Arnau Mora
7c6474ce91 Fix edge to edge issue (#1515)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-05 11:54:48 +02:00
Ricki Hirner
de8c1d160d Support Fastmail OAuth (#1509)
* Add Fastmail OAuth login implementation

* [CI] Run tests on API level 36, too

* Add Fastmail OAuth login support

* Remove logging and move companion object to bottom

* Remove FastmailLogin and GoogleLogin to OAuthLogin and OAuthGoogle

- Remove FastmailLogin class
- Refactor GoogleLogin class to OAuthGoogle object
- Update AndroidManifest.xml to use ${applicationId} for OAuth redirect URI
- Add OAuthFastmail object for Fastmail OAuth integration
- Update GoogleLoginModel and FastmailLoginModel to use OAuthGoogle and OAuthFastmail respectively
- Add OAuthIntegration object for shared OAuth functionality

* Update Fastmail authentication error message and add redirect URI documentation

* Add error handling for refresh token exception
2025-06-05 11:54:14 +02:00
Ricki Hirner
fa50fe4c30 [CI] Run tests on API level 35 2025-06-04 16:14:00 +02:00
Arnau Mora
ba4d3b2fd1 Increase SDK to 36 (#1513)
* Upgrade to SDK 36

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

* Enable on back invoked callback

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

* Remove `enableOnBackInvokedCallback`

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-04 15:42:57 +02:00
Sunik Kupfer
0fed85fdc3 Add app password hint under password field (#1507)
* Add app password hint under password field

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Change text to use prefer

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Append path encoded

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update app password help URL and text

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-06-04 15:42:40 +02:00
Sunik Kupfer
6fbaea9487 [SyncManager]s Remove authority (#1491)
* Update sync stats to store sync data type instead of authority

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use a real authority in the tests

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Replace authority with syncDataType in sync managers

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Minor changes
- import index
- edit comments

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use lowercase localised strings for datatypes

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Pass sync data type extra as string

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove unknown datatype

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move datatype name strings to collection screen section

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update string usages

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update test

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add any type annotations to arrays

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-04 14:44:12 +02:00
Arnau Mora
fc2bc8aa47 Pass state to modal drawer for automatic back handler (#1495)
* Pass state to modal drawer for automatic back handler

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

* Fix deprecation: Use toUri instead of Uri.parse

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-02 17:12:41 +02:00
Ricki Hirner
0321e4ab8f [Lint] Convert URIs to strings using toUri() (#1506) 2025-06-02 12:00:13 +02:00
Ricki Hirner
711543c5f1 Credentials / dav4jvm: store passwords as CharArray (#1483)
* Credentials / dav4jvm: store passwords as CharArray

* Fix tests
2025-05-30 17:37:14 +02:00
Ricki Hirner
5c485834e9 Update Gradle wrapper and Android Gradle Plugin versions 2025-05-30 17:36:10 +02:00
Ricki Hirner
f349f1fec8 Bump version to 4.4.11 2025-05-30 16:28:23 +02:00
Ricki Hirner
e6413506cb Fetch translations from Transifex 2025-05-30 16:26:53 +02:00
Michael Biebl
d4b5039297 Use lowercase GroupIDs as a workaround for jitpack.io issues (#1489)
Related discussion at

https://github.com/bitfireAT/davx5-ose/discussions/1236

and

https://github.com/jitpack/jitpack.io/issues/3295#issuecomment-2329134019

For example trying to access:
https://jitpack.io/com/github/bitfireAT/dav4jvm/b87d772e44/ I get:

File not found. Build ok

In comparison under
https://jitpack.io/com/github/bitfireat/dav4jvm/b87d772e44/ I get:

build.log
dav4jvm-b87d772e44-sources.jar
dav4jvm-b87d772e44.jar
dav4jvm-b87d772e44.module
dav4jvm-b87d772e44.pom
dav4jvm-b87d772e44.pom.md5
dav4jvm-b87d772e44.pom.sha1
2025-05-26 13:53:21 +02:00
Ricki Hirner
979f2257de Bump version to 4.4.11-rc.2 2025-05-22 19:43:03 +02:00
Ricki Hirner
3efb8d5c62 TaskSyncer: accept nullable ResyncType (#1487)
Fix TaskSyncer constructor to accept nullable ResyncType
2025-05-22 19:42:34 +02:00
Ricki Hirner
ec657519a9 Bump version to 4.4.11-rc.1 2025-05-22 17:33:24 +02:00
Sunik Kupfer
a835557b35 Handle when AccountActivity is started without account (#1481)
* Pass new account as non-nullable

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Move companion object

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Handle missing account in intent by logging and redirecting to accounts overview

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix deprecation

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Also check account exists

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Simplify toast message

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add account name to toast message

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Don't check for invalid accounts in AccountActivity; handle InvalidAccountException in model

* Minor changes

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-22 17:22:31 +02:00
Arnau Mora
19f86670bf Implemented sort order for DAV documents (#1434)
* Implemented sort order

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

* Fixed column name

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

* Implemented sort order

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

* Fixed column name

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

* Improved issues

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

* Add test for WebDavDocumentDao.getChildren for ORDER BY

* Converted getChildren into a raw query

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

* Fix formatting of SQL query in WebDavDocumentDao

* Refactoring

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

* Drop comment

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

* Changed default sort to show directories first

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

* Fixed tests

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

* Switched to query constructor instead of in-place

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

* Changed log method

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

* Add DocumentSortByMapper to handle orderBy mapping for WebDavDocument queries

* Rename DavDocumentsProviderTest to DocumentSortByMapperTest and update test method name

* Refactor sorting and mapping

* Add "order by name" as last criterion

* Remove default sort order constant from DocumentSortByMapper and use WebDavDocumentDao.DEFAULT_ORDER instead

* Adapt comments

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-22 09:48:12 +02:00
Ricki Hirner
f74d14e2a2 Always append one-time syncs (#1482)
* Refactor sync worker manager to always append one-time syncs (but only once)

* Adapt KDoc
2025-05-21 15:57:10 +02:00
Ricki Hirner
57ef059099 [CI] Also run tests on push to main branch 2025-05-21 11:59:12 +02:00
Ricki Hirner
f157a819b7 Bump version to 4.4.11-beta.1 2025-05-21 11:55:51 +02:00
Ricki Hirner
9bbc4c096d Update Compose BOM version to 2025.05.01 2025-05-21 11:55:51 +02:00
Sunik Kupfer
b306219015 [Push] Append sync when already syncing (#1480)
* [WIP] Add delay to sync worker and append push syncs

* Enqueue sync work with append policy for push notifications

* Remove delay

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Also check for enqueued work

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-21 11:54:18 +02:00
Ricki Hirner
469c30b511 [CI] Run tests on pull requests instead of git pushes (#1479)
* [CI] Run tests on pull requests instead of git pushes

* Add comment to PR trigger in test-dev.yml

* Update concurrency group for development tests
2025-05-21 11:30:24 +02:00
Sunik Kupfer
05f6c7ab0b Reset subscription URLs on distributor change (#1467)
* Move distributor set/get to PushRegistrationManager

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Handle unsubscription after manual distributor change before resubscribing

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Minor changes

* Update distributor check to use method of the own class

* Use mutex and add KDoc

* UnifiedPush registration: add service type to "message for distributor"

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2025-05-19 16:42:40 +02:00
Ricki Hirner
eeb94d4039 [Sync] Replace extras Bundle by explicit arguments (#1475)
* Remove extras parameter from sync managers

* Refactor sync worker parameters and improve documentation

* Adapt tests

* Rename resyncType to resync in syncers, adapt KDoc

* Remove legacy sync result comment
2025-05-19 15:37:50 +02:00
Ricki Hirner
7bf9172bdc SyncManager: directly wrap HTTP calls with runInterruptible (#1472)
* SyncManager: Handle CancellationException in sync process

* Wrap remote resource operations with runInterruptible; provide suspending SyncException wrappers

* Update more sync methods to use runInterruptible only for the HTTP request itself

* Make downloadRemote methods suspendable and use runInterruptible for better concurrency handling

* Update code style

* Fix tests

* Refactor SyncException to use runBlocking for wrapping resources
2025-05-19 12:54:51 +02:00
Arnau Mora
dec5be5690 Display FCM instead of DAVx⁵ in distributor settings (#1468)
* Display FCM instead of DAVx⁵ in distributor settings

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

* Update app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-19 10:49:19 +02:00
Ricki Hirner
d8e8129d7b Bump version to 4.4.11-alpha.1 2025-05-14 13:49:05 +02:00
Ricki Hirner
c9fb7dc7a2 Update dependency 2025-05-14 13:46:47 +02:00
Arnau Mora
ededcb98e1 Added widget with just a sync icon (#1340)
* Renamed existing widget

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

* Added sync icon widget

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

* Added labels for widgets

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

* Added widget preview

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

* Removed max width restriction

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

* Changed widget description

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-14 08:50:50 +02:00
Arnau Mora
fc10a315d5 Added warning about credentials in debug info (#1457)
* Added warning about credentials in debug info

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

* Updated warning message

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

* Changed descriptive icon

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

* Replace "alert" by "notice"

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-13 15:45:52 +02:00
Ricki Hirner
cfeb6b3974 Make SyncManager suspending (first part) (#1451)
* Introduce coroutine dispatcher for sync operations

* Make `logSyncTimeBlocking` and `insertOrReplace` suspend functions

* Suspend first bunch of SyncManager methods; move sync dispatcher usage to SyncManager

* [WIP] Fix tests

TODO: extract test framework changes to separate PR

* Remove mainDispatcher from SyncManagerTest

* Remove explicit coroutineScope naming
2025-05-12 15:52:54 +02:00
Ricki Hirner
a15902e586 [DI] Make backing field of TestDispatcher + scheduler private 2025-05-10 09:17:22 +02:00
Ricki Hirner
47afddbd08 Simplify running suspending tests (#1452)
* Move LoggerModule to di package and add TestLoggerModule

* Remove main dispatcher injection and use runTest directly

* Add verbose logging module for tests

* Add test for UnifiedPushService.onUnregistered
2025-05-09 14:55:30 +02:00
Ricki Hirner
5f647b7403 Move GetDelayUntil tests to dav4jvm 2025-05-09 12:18:16 +02:00
Ricki Hirner
d460e4ca7b Update dav4jvm 2025-05-09 11:50:45 +02:00
Ricki Hirner
827a1b954f Update version code and name and increase max build metaspace size to 1GB 2025-05-09 10:59:34 +02:00
Ricki Hirner
54bcda1bb4 Update Android Gradle Plugin and Compose BOM versions 2025-05-08 13:27:13 +02:00
Ricki Hirner
7003c5f730 Bump version to 4.4.10-rc.1 2025-05-07 18:03:28 +02:00
Ricki Hirner
4ccf99ce23 Fetch translations from Transifex 2025-05-07 18:03:03 +02:00
Ricki Hirner
3fca4d60f1 Update dependencies 2025-05-07 18:02:57 +02:00
Ricki Hirner
2099f47d22 Use ical4android that removes ClassLoader checks; refactor SyncDispatcher (#1446)
* Use ical4android that removes ClassLoader checks; refactor SyncDispatcher

* [Tests] Add SyncDispatcher provider for tests

* Add sync dispatcher with fixed thread pool

* Replace fixed thread pool context with thread pool executor (as we had it previously, but without setting the contextClassLoader)

* Replace sync dispatcher with I/O dispatcher with limited parallelism
2025-05-07 17:57:46 +02:00
Sunik Kupfer
c12a723a52 Send Push-Dont-Notify header (#1444)
* Update dav4jvm

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add push-dont-notify header to requests when push subscription has not expired

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix mocks in TestSyncManager

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use relaxed mockk for collection

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use lazy val for pushDontNotifyHeader

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use null instead of empty map for pushDontNotifyHeader

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Get push subscription state as calculated collection property

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add tests for active push subscription status in collection

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename test methods

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Return active subscription or null

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use empty map instead of null for pushDontNotifyHeader

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix test

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Always send push-dont-notify header if subscription exists

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix copyright

* Send Push-Dont-Notify URL as quoted string

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-07 17:22:54 +02:00
Arnau Mora
eb0b75a9a7 [Push] Better title/description for Google FCM in UI (#1424)
* Added FCM indicator

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

* Changed text

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

* Added content description

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

* Add link to manual for WebDAV push in app settings

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-02 12:06:23 +02:00
Arnau Mora
94ca9cd871 Fixed deprecations with menu anchors (#1428)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-30 11:58:01 +02:00
Ricki Hirner
9f697f06be Repositories: apply new naming scheme (#1423)
* DavHomeSetRepository, DavServiceRepository: apply new naming scheme for (non-)suspending calls

* AccountRepository: apply new naming scheme for (non-)suspending calls

* Update repository methods to new naming scheme
2025-04-30 10:58:00 +02:00
Ricki Hirner
ba7f95aad5 Clean up app/build.gradle (#1421)
* Remove packaging resources exclusion and configurations

* Update mikepenz-aboutLibraries to 12.1.0-rc03 and configure resource merging for LICENSE files
2025-04-27 11:10:20 +02:00
Ricki Hirner
993fffaa15 Fix UnifiedPushService tests (#1420) 2025-04-27 10:21:17 +02:00
Ricki Hirner
4b7f7ed45e Fetch translations from Transifex 2025-04-27 10:03:44 +02:00
Ricki Hirner
7e80607a34 Bump version to 4.4.10-beta.1 2025-04-27 10:01:41 +02:00
Ricki Hirner
d0389f13fc Move Hilt modules to .di package (bitfireAT/davx5#665)
Move Hilt module definitions to di package
2025-04-26 23:21:27 +02:00
Sunik Kupfer
993d0f66ec [Push] Update subscriptions when collection is (un)selected for sync (#1407)
* Update subscriptions when collection is (un)selected for sync

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove collections change listener and its hilt module

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove observer pattern to listen for collection changes

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update push subscriptions when collection is (un)selected for sync

* Update collection selection listener to use lazy initialization

* Update push subscriptions and sync when collection is (un)selected for sync

* Update app/src/main/kotlin/at/bitfire/davdroid/ui/CollectionSelectedUseCase.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Move CollectionSelectedUseCase to account package

* Add test for CollectionSelectedUseCase

* Inject application scope instead of using a factory

* Update tests to run on our main dispatcher

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-26 22:13:40 +02:00
Arnau Mora
4c9ad959dd Upgrade about libs to 12.1.0-rc02 (#1416)
* Upgrade about libs to 12.1.0

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

* Update AboutLibraries version to 12.1.0-rc02

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-26 14:43:51 +02:00
Ricki Hirner
044a28138a [Push] Don't block main thread in UnifiedPushService (#1412)
* Refactor UnifiedPushService to use CoroutineScope so that it doesn't block on main thread

* Add KDoc why the scope is not cancelled
2025-04-25 10:36:02 +02:00
Ricki Hirner
e8ec98c257 Fix HttpClient.Builder.followRedirect 2025-04-25 10:30:53 +02:00
Ricki Hirner
fe0c1e67e7 Drop LiveData (#1414)
* Remove LiveData dependencies
2025-04-25 09:59:08 +02:00
Ricki Hirner
7261a8137d [IDE] Code style, width 2025-04-25 09:05:43 +02:00
Ricki Hirner
b1493f3f6a Bump version to 4.4.10-alpha.2 2025-04-24 20:08:30 +02:00
Ricki Hirner
d679dc5e97 [Push] Fix various subscription problems (#1411)
* Handle non-existing account gracefully when (un)subscribing collections

* Unsubscribe from all (subscribed) collections when no push distributor is selected
2025-04-24 20:03:39 +02:00
Ricki Hirner
b0f7196f2b Fix non-suspending DB access on main thread in accountRepository.delete() (#1409)
Make more database queries suspendable and run them in a blocking context
2025-04-24 19:02:22 +02:00
Ricki Hirner
77a6e5c5ab Bump version to 4.4.10-alpha.1 2025-04-24 13:12:38 +02:00
Ricki Hirner
845d979046 Update dependency versions 2025-04-24 13:12:14 +02:00
Arnau Mora
f62509ed80 [Push] Support UnifiedPush Connector 3.x, VAPID, Encryption, Google FCM (#1325)
* Upgrade UnifiedPush Connector to 3.0.4

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

* Updated overrides

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

* Added storing keys and auths

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

* Excluded tink

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

* Fixed deprecations and calls

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

* Integrate UnifiedPush 3.x connector and FCM distributor

* Integrate UnifiedPush connector 3.x, use VAPID and message encryption

* [WIP] Refactor push registration logic and remove deprecated methods

* [WIP] Remove PushRegistrationWorkerManager and refactor PushRegistrationManager

* Remove unused service repository dependency and update worker to suspend

* Add suspend modifier to DAO methods and repository methods

* Add runBlocking to getByService call in CollectionListRefresherTest

* Add documentation for UnifiedPushService and PushRegistrationManager

* Add fallback for push messages without topic

* [WIP] Add UnifiedPushService test with workaround for PushService binder

* Update UnifiedPush library version and clean up test code

* Refactor push message handling, synchronization and coroutines

* Add coroutine dispatchers for push registration and unregistration

* Add async support for push subscription updates

* Refactor unsubscribe logic into reusable method

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-24 13:09:51 +02:00
Ricki Hirner
e79c362f46 [WebDAV] Refactor coroutine usage (#1405)
* Inject coroutine scopes for cancellation and I/O

* Inject coroutine scopes for better cancellation and lifecycle management

* Inject coroutine scopes for WebDAV operations

* Fix tests

* More correct coroutine contexts
2025-04-24 12:12:17 +02:00
Arnau Mora
5c35741226 Update AboutLibraries to 12.0.0 (#1406)
* Upgrade aboutlibs to 12.0.0

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

* Migrated code

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

* Apply AboutLibraries after other gradle plugins

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-24 10:33:59 +02:00
Ricki Hirner
b90b8ce6a2 Update AGP to 8.9.2 2025-04-23 17:30:29 +02:00
Sunik Kupfer
321aeedd8f Sync after sync flag or forceReadOnly flag of a collection are changed (#1383)
* Perform delayed sync on a collection UI change

- sync state
- force-read-only state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use withContext to access DB on background thread

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove return comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Extract delay value to constant

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use accountRepository to create account from name

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-04-23 17:25:12 +02:00
Arnau Mora
09f68a237b NPE when trying to log in without specifying an email (#1401)
* Added check for null baseUri

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

* Rollback

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

* Added check for IME next

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-22 10:44:21 +02:00
Ricki Hirner
93d715bb99 Test coroutines with runTest instead of runBlocking (#1399)
Add kotlinx-coroutines-test dependency and replace runBlocking with runTest in tests
2025-04-22 10:44:08 +02:00
Arnau Mora
04fe8e1aca Hide "keep permissions" in API 29 and below (#1400)
* Hide "keep permissions" for API 29 and below

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

* Fix deprecation

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-22 09:35:12 +02:00
Ricki Hirner
1f02f3cc27 Use R8 full mode (#1397)
* Use R8 full mode

* Add dav4jvm dependency (which includes an XmlPullParser) for testing
2025-04-20 12:53:17 +02:00
Ricki Hirner
a0acd4e929 Use ProGuard rules from libraries (#1387)
Update library versions and optimize ProGuard rules (most of them are now in the libraries consumer rules)
2025-04-20 12:46:46 +02:00
Ricki Hirner
3901e6ebe4 Update translation fetching script to use relative paths 2025-04-20 10:30:11 +02:00
Ricki Hirner
f229226521 Bump version to 4.4.9 2025-04-20 10:26:36 +02:00
Ricki Hirner
6644e4acd7 Fix 4.4.9 release compile error (#1386)
Update ical4android version and force Apache Commons dependencies
2025-04-17 16:39:23 +02:00
Ricki Hirner
d46f8056a5 Fetch translations from Transifex 2025-04-17 12:55:02 +02:00
Ricki Hirner
c3731ace88 Version bump to 4.4.9-rc.1 2025-04-17 12:53:21 +02:00
Ricki Hirner
038c2df524 Update dependencies 2025-04-17 12:52:20 +02:00
Arnau Mora
400318b390 Fixed paddings on horizontal mode (#1353)
* Fixed paddings

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

* Added padding to all screens

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

* Fixed edge to edge on accounts screen

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

* Fixed e2e issues

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

* Undo edge-to-edge

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

* Rollback

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

* Fixed paddings

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

* Fixed padding consumption

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

* Got rid of ime padding

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

* Fixed issues with paddings

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

* Fix UI elements partially obscured in landscape mode

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-17 12:47:59 +02:00
Arnau Mora
0d2e5a1f07 Avoid NPE when missing DTSTART for recurring events (#1336)
* Added handling of null dtstart

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

* Added JtxSyncManagerTest

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

* Test recurrence id without dtstart does not cause NPE

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Extract syncmanager creation from try-catch

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add tests

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Assert RRULE remains in main vtodo

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Skip tests when jtx board not installed

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Correct annotation

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Simplify null checks

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Extract recurid definition

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update ical4android

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Find recurrence instance without dtstart

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename method for clarity and update kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use new method in test too

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Fix lint warnings

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use a custom rule to wrap and ignore security exception if jtxboard is not installed

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Use existing permission utils

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename capture to catch exceptions rule

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Format code

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2025-04-16 14:49:44 +02:00
Ricki Hirner
9835cd0d53 Refactor SharedPreferences edits to use Kotlin extension function (#1382) 2025-04-15 11:46:17 +02:00
Bernhard Stockmann
f6d8efcd26 Update Screenshots (#1380)
now reflects latest UI
2025-04-14 13:32:27 +02:00
Ricki Hirner
955de83b35 Add VAPID support to Collection entity (#1376) 2025-04-13 11:41:30 +02:00
Ricki Hirner
29a09f2038 Update dependencies, including dav4jvm (#1374)
* Update dependencies

* Remove unused import and simplify topic extraction

* Fix room issue
2025-04-13 10:26:34 +02:00
Ricki Hirner
fff332f31f Enable dependabot for Github Actions (#1363) 2025-04-01 17:28:24 +02:00
Ricki Hirner
b4d4a2fddd Update Android Gradle Plugin and Compose BOM versions 2025-04-01 10:40:56 +02:00
Arnau Mora
566a539a85 Debug Info does not contain actual error (#1362)
Fixed typo on intent extra

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-01 10:20:03 +02:00
Arnau Mora
e588ada891 Original string for webdav_add_mount_empty_more_info broken (#1354)
Got rid of extra `</string>` tag inside of CDATA

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-03-23 11:36:16 +01:00
Sunik Kupfer
0c89e3ba3b Version bump to 4.4.8 2025-03-13 12:44:38 +01:00
Sunik Kupfer
7b0e134c20 Fetch translations from Transifex 2025-03-13 12:40:43 +01:00
Ricki Hirner
8f7c285cb7 Move InvalidAccountException to account package (#1342)
Move InvalidAccountException
2025-03-13 11:21:44 +01:00
Arnau Mora
a2cddfc012 Release 4.4.8-rc.1 (bitfireAT/davx5#656)
* Increase version code and modify version name

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-03-13 11:21:44 +01:00
Sunik Kupfer
54eaecc6b5 Backport of bitfireAT/davx5#655: honor build config decision on whether users are allowed to accept custom certificates
* Honor customCertsUI build config value on whether users are allowed to accept custom certificates

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Extract config evaluation; Update kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-03-11 12:37:55 +01:00
Sunik Kupfer
0012dec482 Track app foreground state via AppTheme using separate object (#1335)
* Track app in foreground state via AppTheme using separate object

* Make inForeground read only; add kdoc

* Remove type
2025-03-05 11:13:41 +01:00
Ricki Hirner
ced6abea3f Update AGP and dependencies 2025-03-05 10:41:35 +01:00
Sunik Kupfer
4a82baeaea Remove special chars in address book account names (#1334)
* Remove special chars from address book account names

* Add a test

* Update kdoc

* Only remove iso control and SQL problematic chars
2025-03-05 09:51:39 +01:00
Sunik Kupfer
f41b4fd59d Show a notification at sync when ContentProvider inaccessible (#1278)
* Extract notification handling to separate class

* Notify user at sync if content provider is missing

* Dismiss only content provider specific notification

* Remove title from notification text body

* Move sync warning strings into their own block

* Add KDoc, duplicate method for clarity

* Show message in notification for disabled tasks apps

* Pass authority through method calls

* Shorten method names

* Don't show content provider error notification when missing permission

* Rename methods and remove obsolete var

* Add spacing in content provider missing warning

* Improve kdoc

* Remove obsolete tasks provider error messages

* Syntactic sugar

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-03-04 13:36:29 +01:00
Ricki Hirner
f6bd4b0fc2 Update issue templates to support issue types and allow blank issues (for internal use) 2025-03-01 12:47:36 +01:00
Ricki Hirner
50879b6a0c Update AGP, Kotlin, dependencies 2025-02-27 13:14:47 +01:00
Arnau Mora
6be42d4ec3 Added timestamp to debug info (#1323)
* Added timestamp to debug info

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

* Added timestamp to `DebugInfoActivity.IntentBuilder`

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

* Show local time and UTC of timestamp

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-25 20:00:22 +01:00
Sunik Kupfer
a56d42d9a5 Provide test address books via DI (#1310)
* Add LocalTestAddressBookStore.kt

* Update tests acquiring local test address books

* Remove unused methods from LocalTestAddressBook

* Extract dirty check methods to the single test class using them

* Remove unused read only flag

* Drop obsolete context

* Reusables as properties

* Rename LocalTestAddressBookStore to LocalTestAddressBookProvider

* Minor changes

* Remove test address book after provision finishes and don't remove all before

* Fix more tests by removing address book accounts after run, not before

* Wrap provision method call in try-finally

* Rename provide methods anonymous function param for clarity

* Extract account recreation to variable

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-25 17:27:45 +01:00
Ricki Hirner
e34952bca9 Update dependencies 2025-02-19 12:58:36 +01:00
Arnau Mora
9d293a00e7 Added confirmation dialog for "Distrust System Certificates" (#1307)
* Added confirmation dialog for dsc

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

* Automatic dialog dismiss

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

* Added preview, and changed texts

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

* Updated dialog

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-02-15 23:04:32 +01:00
Arnau Mora
1ee41f8027 Use TaskStackBuilder instead of PendingIntent.getActivity (#1293)
* Use `TaskStackBuilder` instead of `PendingIntent.getActivity`

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

* Replaced usages for addNextIntentWithParentStack

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

* Remove unused import

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-13 18:53:10 +01:00
Sunik Kupfer
d3c1dbb5da DnsRecordResolverTest: Use seeded random number generator for deterministic test result (#1306)
* Use seeded random number generator for a 100% deterministic test result

* Fix typo

* Rename variable

* Mock random numbers and assert corresponding record is found
2025-02-12 17:50:50 +01:00
Sunik Kupfer
cd554d885b Skip login type selection when logging in via intent (#1267)
* Move companion object to the end of class

* Skip login type selection when logging in via intent

* Skip login type page if not default login type

* Add test for implicit email intent

* Fix test

* Update KDoc

* Refactor URI handling in LoginActivity and StandardLoginTypesProvider

* Skip login type page if intent is clear, but don't skip when using defaultLoginType

* Log unclear intents

* Use data class instead of pair

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-11 10:58:15 +01:00
Ricki Hirner
d9b4149d41 Refactor: replace deprecated getShowOnlyPersonalPair method and simplify removeAccount logic (#1304) 2025-02-07 11:55:16 +01:00
Arnau Mora
4af6165094 Backport login_credentials_lock for password change in account settings (bitfireAT/davx5#643)
---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-02-07 11:41:47 +01:00
Ricki Hirner
fb2023762d Add syncActive flag to control SyncAdapter behavior during tests (#1302) 2025-02-07 11:26:10 +01:00
Ricki Hirner
946c450036 SyncAdapter: Hilt error handling (#1299)
* SyncAdapter: Hilt error handling

* HiltTestRunner: enforce Android P requirement for MockK

* Update comment
2025-02-06 17:29:14 +01:00
Ricki Hirner
969d92d037 Bump version to 4.4.7 2025-02-02 18:30:35 +01:00
Ricki Hirner
6998f009c4 Fetch translations from Transifex 2025-02-02 14:57:15 +01:00
Ricki Hirner
cba1f01bdb Version bump to 4.4.7-rc.1 2025-02-02 14:56:29 +01:00
Ricki Hirner
5f80c8e779 Add migration for Syncer URL → ID change (#1285)
* Add AccountSettingsMigration20 to handle collection ID migration for syncer

* Add success path tests for address books and calendars

* Increase CURRENT_VERSION, fix task list store
2025-02-02 14:55:39 +01:00
Ricki Hirner
5ece438b3f Open-source intro page: don't select a "dont show for... months" option by default (#1287)
Refactor RadioButtons component to support initial selection and update parameter names for clarity
2025-02-02 14:46:50 +01:00
Ricki Hirner
acd4e41f8b Update cert4android to avoid Conscrypt crash in custom certificate handling (#1290) 2025-02-02 14:35:36 +01:00
Ricki Hirner
ae08093906 Syncer SecurityException: disable permission notification for now (#1284) 2025-02-02 14:28:07 +01:00
Ricki Hirner
dd456b41f1 Version bump to 4.4.7-beta.1 2025-01-31 22:52:19 +01:00
Ricki Hirner
30283f36a4 Update dependencies 2025-01-31 22:51:53 +01:00
Ricki Hirner
4858dd9229 Move authority and ContentProvider creation to LocalDataStore (#1272)
* Move authority and content provider acquire() to LocalDataStore implementations

* Fix tests

* Update LocalCalendarStore.kt

Reroder LocalCalendarStore delete
2025-01-30 12:42:11 +01:00
Sunik Kupfer
b910ba25ae Use ID to match DB collections with content provider collections (#1274)
* Update ical4android

* Match DB collections with content provider collections via ID

* Minor renaming and KDoc

* Move string constant to companion object

* Update KDoc

* Use getOrDefault to be more explicit

* Remove exception throw on missing collection ID

* Rewrite LocalAddressBookStoreTest

* Minor changes
- remove unused param
- make companion methods internal

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-29 16:47:51 +01:00
Arnau Mora
2a542210ca MKCALENDAR does not send valid calendar-timezone (#1251)
Fixed missing calendar properties

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-28 12:39:24 +01:00
Sunik Kupfer
eef85f1f7a Provide three options for donation reminder time (#1256)
* Provide three options for donation reminder time

* Move radio buttons to top card; Change strings

* Use padding instead of height for radio elements
2025-01-27 17:50:12 +01:00
Ricki Hirner
4f2d4e3a49 Further unmockk after tests 2025-01-27 16:47:31 +01:00
Ricki Hirner
feccb76ce8 MockK: use MockkRule if possible (#1269)
Unmockk all
2025-01-27 16:24:44 +01:00
Ricki Hirner
835689a4a6 Use DI for HttpClient.Builder (#1250)
* [WIP] Use Hilt for HttpClient.Builder

* Inject Provider<HttpClientBuilder> when necessary

* Proxy support

* [WIP] Tests

* Fix tests

* Minor changes, enable cache support again

* Update HttpClient to use modern TLS connection specification (disable TLS 1.0 and 1.1)

* Use real CredentialsStore
2025-01-27 13:44:33 +01:00
Ricki Hirner
50cbac147e [CI] Use if: !cancelled() instead of if: always() (#1266)
To make jobs cancellable,
see https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#always
2025-01-27 13:02:38 +01:00
Ricki Hirner
d1dd2f016e Fetch translations from Transifex 2025-01-25 12:54:34 +01:00
Ricki Hirner
bd5e26a9a9 Version bump to 4.4.6 2025-01-24 17:55:54 +01:00
Sunik Kupfer
2d686bee01 Show a warning if calendar or contacts storage is deactivated or missing (#1243)
* Add AppTheme to previews

* Show warning when contacts or calendar system apps are missing or disabled

* Change android icon to database missing icon

* Remove duplication

* Use packageChangedFlow to observer live changes

* Send user to settings app when deactivated and manual when missing

* Find whether content provider app is available by authority

* [WIP] Minor changes

* Open "Manage apps" instead of manual

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-24 17:52:17 +01:00
Arnau Mora
2438f1a8d4 Replaced LocalContext casting with LocalActivity (#1261)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-22 21:17:00 +01:00
Ricki Hirner
331f8d5743 Bump version to 4.4.6-rc.1 2025-01-22 15:53:40 +01:00
Ricki Hirner
7e43524ff5 Update dependencies 2025-01-22 15:53:00 +01:00
Arnau Mora
708d94b69b Debug info: print all workers (#1200)
* Added printing of debug info for AccountsCleanupWorker

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

* Improved docs

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

* Fixed issues

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

* Cleanup

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

* Move debug info generation into separate class

* Minor changes

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-22 15:27:28 +01:00
Arnau Mora
8b3c36f702 Use DB StringDefs also in DAOs and methods where they are used (#1252)
* Added annotations to daos

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

* Added missing annotations on methods

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

* Missing annotation

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-22 11:35:37 +01:00
Sunik Kupfer
ca2d57cf61 Change warning when Internet is not available (#1255) 2025-01-22 11:33:58 +01:00
Sunik Kupfer
1cc9b4bdd1 Fix show only personal setting not updating the view immediately (#1238)
* Split pair of show only personal settings

* Create and consume showOnlyPersonal settings separately

* Fix showOnlyPersonal flow state change not triggering re-emission

* Combine if statements

* Minor refactoring (lift out "if")

* Create separate reload methods

* Reload on model creation

* Use viewmodel scope

* Move init after relevant method declarations

* Add kdoc

* Remove deprecated getShowOnlyPersonalPair

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-20 13:45:50 +01:00
Sunik Kupfer
226583f19e Don't remove port from URI of nextcloud login intent (#1242)
* Test loginFromIntent

* Fix given URI

* Add test

* Use URI authority directly

* Add test for implicit intents

* Add URI port only if not -1

* Modify URI using Java URI

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-16 14:34:51 +01:00
Ricki Hirner
f3333b7b54 Update AUTHORS and copyright notices (#1232)
* Update AUTHORS

* Add Android Studio copyright profiles

* Update copyright notices
2025-01-10 16:41:37 +01:00
Sunik Kupfer
226560230d Collection list refresh: Don't update fetched homesets (#1222)
* Collection list refresh: Don't update home sets that have been fetched already

* Expand testDiscoverHomesets for personal flag

* Add comment

* Rename property; Update its kdoc

* Make class properties function params

* Extract home set class and property definitions for home set discovery

* Pull out HomeSetClassName and property names

* Minor KDoc changes

* Move collection and principal query properties

* Make properties private

* Make collectionProperties service-specific; drop unused SupportedAddressData

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-10 15:34:48 +01:00
Sunik Kupfer
6a08497b3a Make room entity properties immutable (#1218)
* Make entity properties immutable where possible

* Make WebDavDocument room entity fully immutable

* Make HomeSet room entity fully immutable

* Make Collection room entity fully immutable

* Minor change

* KDoc, use transaction for combined read/write access

* Minor changes

---------

Co-authored-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-10 15:02:51 +01:00
Ricki Hirner
356183084f Update AGP, dependencies 2025-01-10 14:11:08 +01:00
Ricki Hirner
5ea7273c94 Update dependencies 2025-01-09 15:29:49 +01:00
Sunik Kupfer
843013a0f0 Use StringDef to annotate possible service and collection types (#1227)
* Add StringDef annotation to collection type param

* Add StringDef annotation to service type param
2025-01-09 12:08:11 +01:00
Ricki Hirner
ac8de37b6f Remove test account type (#1224)
* [WIP] Remove "test account" account type

* Fix tests
2025-01-08 16:49:12 +01:00
Sunik Kupfer
62dc374774 Drop address books authority (#1217)
* Remove obsolete method

* Remove address_books_authority

* Don't use contacts authority needlessly
2025-01-07 12:39:16 +01:00
Ricki Hirner
1f83e1bf12 Version bump to 4.4.6-beta.1 2025-01-03 14:13:56 +01:00
Arnau Mora
4c9b67a9e5 Rename account with jtx collection: IllegalArgumentException (#1198)
* Moved account renaming to `LocalTaskListStore` and trying to fix issue

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

* Fixed missing import

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

* Improved renaming algorithm

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

* Added `asSyncAdapter`

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

* Split account renaming responsibility

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

* Split account renaming responsibility

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

* Got rid of unused `SettingsManager`

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

* Simplified updateAccount on LocalDataStore

* Added explanatory comment

* Changed provider acquiring to the store one

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

* LocalTaskListStore takes provider name instead of authority

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

* Got rid of throws

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

* Simplified expressions

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

* Added renaming of calendar accounts

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

* Fixed imports

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

* Moved calls to try-catch

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

* Typo

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

* Ignore exceptions of every store.updateAccount()

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-03 10:09:27 +01:00
Ricki Hirner
4cbe03b351 Hide sync entries in system accounts (#1214)
Hide sync entries in system settings; don't allow to manage accounts over system accounts anymore
2025-01-01 19:02:30 +01:00
Ricki Hirner
365f87991a DB: move migrations to separate files and use DI (#1206)
* DB: move migrations to separate files

* Use Hilt for AutoMigrationSpecs

* Tests in separate package

* Use Hilt for explicit Migrations
2024-12-31 16:24:50 +01:00
Ricki Hirner
77a795dfe5 Use bundleOf and contentValuesOf, if applicable (#1204)
Use bundleOf and contentValuesOf if applicable
2024-12-30 11:33:25 +01:00
Arnau Mora
794007fa38 [Push] Upon notification, only enqueue sync for the respective service type (#1175)
* Added enqueuing of the correct authority

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

* Using new `SyncDataType`

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

* Refactoring

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

* Added VEVENT check

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

* Style updates

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-29 12:31:17 +01:00
Arnau Mora
1e17e1883b Trimmed URLs for URI generation (#1202)
Trimming URLs for uri generation

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-28 14:14:09 +01:00
Ricki Hirner
48ecb5e008 Bump version to 4.4.6-alpha.1 2024-12-26 18:38:32 +01:00
Ricki Hirner
f503ce5ff6 Sync workers: use data type enum instead of specific authority (#1177)
* Introduce SyncDataType for Workers

* Fix tests, remove SyncWorkerManager.enqueueOneTime(authority)

* TasksAppManager.currentProviderFlow(): return Flow instead of StateFlow

* Simplify TasksAppManager and TasksAppWatcher

* [WIP] AutomaticSyncManager

* [WIP] AutomaticSyncManager

* AccountSettings: optimize imports

* SyncWorkManager: remove deprecated methods

* AutomaticSyncManager: disable unused authorities in sync framework

* Add migration draft

* AccountSettings: minor changes

* Migration, BaseSyncWorker: use sync data type

* Tests, set default sync interval when tasks app is installed, notify on missing tasks app permission during sync

* Remove deprecated AccountSettings methods

* AccountSettings: actually increase version number

* Use automaticSyncManager.updateAutomaticSync where applicable; better handle manual sync interval

* KDoc

* Remove deprecated SyncWorkerManager.syncAuthorities; fix cancelAllWork

* AccountSettings: minor changes

* TasksAppManager: show notification on missing permissions; always update automatic syncs

* AutomaticSyncWorker: only provide updateAutomaticSync() as public method

* AccountSettings: simplify setSyncInterval

* AutomaticSyncManager: disable automatic task sync when no tasks provider is available
2024-12-26 18:34:09 +01:00
Ricki Hirner
98578feeb2 TestUtils: add common method to initialize WorkManager for instrumentation tests 2024-12-25 20:05:37 +01:00
Ricki Hirner
0762cc6c27 AccountSettings: allow to create new instances during migrations (#1195)
AccountSettings: allow to create new instances during migrations
2024-12-25 19:50:40 +01:00
Ricki Hirner
b267291e93 TasksAppManager: use Flow instead of StateFlow 2024-12-25 12:42:59 +01:00
Ricki Hirner
eb8db47cea Simplify TasksAppManager and TasksAppWatcher (#1193)
* Simplify TasksAppManager and TasksAppWatcher

* AutomaticSyncManager: renable setSyncInterval to enable

* TasksAppWatcher: actually select provider
2024-12-24 13:32:30 +01:00
Ricki Hirner
7384feeafb Update dependencies 2024-12-24 10:01:11 +01:00
Ricki Hirner
d10add8367 Fetch translations from Transifex 2024-12-23 14:14:05 +01:00
Ricki Hirner
51bd163069 Version bump to 4.4.5 2024-12-23 14:12:01 +01:00
Ricki Hirner
90280066ee Version bump to 4.4.5-beta.2 2024-12-21 12:55:20 +01:00
Ricki Hirner
03a52e96ad Address book accounts: bind to accounts (again) (#1184)
* [WIP] Scope address book acounts to accounts again

* [WIP] Tests

* Fix LocalAddressBookStoreTest

* Adapt AccountsCleanupWorker

* Migration to assign accounts to address books (again)

* Change account in address books on account rename
2024-12-21 12:53:53 +01:00
Ricki Hirner
5890b3cc5e AccountSettings: one class per migration, tests (#1181)
* [WIP] new AccountSettingsMigrations + tests for v17

* [WIP] Tests

* Finish tests for v17

* Move migrations to separate classes

* Improve test

* KDoc
2024-12-20 11:45:13 +01:00
Ricki Hirner
a02bc56b44 Fetch translations from Transifex 2024-12-17 10:37:17 +01:00
Ricki Hirner
4939c9fc4d Version bump to 4.4.5 2024-12-17 10:19:21 +01:00
Ricki Hirner
c2524b085e LocalAddressBookStore: return all address books, including orphaned ones (#1168)
* LocalAddressBookStore: return all address books, including orphaned ones

* Add test

* Update tests
2024-12-17 10:18:46 +01:00
Arnau Mora
d892dd2b9c Fixed padding problems with Edge-To-Edge (#1171)
* Added navigation bars paddign

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

* Only consuming top padding

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-15 20:01:31 +01:00
Ricki Hirner
95ebce5722 Update Compose 2024-12-12 17:11:22 +01:00
Arnau Mora
4b2f032a57 [Push] Update specification compliance (#1151)
* Update dav4jvm, agp, kotlin and ksp

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

* Updated topic extraction

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

* Updated push-message definition

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

* Newer dav4jvm

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-12-12 17:08:29 +01:00
Ricki Hirner
bc596edfb3 Version bump to 4.4.5-alpha.1 2024-12-07 13:55:02 +01:00
Ricki Hirner
e18534ab9f Use AutomaticSyncManager to manage periodic workers and sync framework (#1157)
* Introduce AutomaticSyncManager

* AccountRepository: TasksAppManager: use AutomaticSyncManager

* Fix AccountSettings.account visibility
2024-12-07 13:52:52 +01:00
Ricki Hirner
9ae03dbc6f AccountSettings: make enable flag for sync intervals explicit (#1156) 2024-12-07 12:41:53 +01:00
Ricki Hirner
042dd3fba2 [DI] Replace injection of Application by Context with ApplicationContext annotation 2024-12-07 12:00:57 +01:00
Ricki Hirner
5d6959c47e Pass sync errors back to sync framework (bitfireAT/davx5#632)
* [WIP] Adapt SyncResult

* Remove numeric stats (not well-defined and not really used)

* Pass "too many retries" or "database error" back to sync framework on hard errors
2024-12-07 11:45:04 +01:00
Arnau Mora
239038ab77 Get rid of Head response cache (#1155)
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-06 11:36:24 +01:00
Ricki Hirner
7097bf9523 Move BaseSyncWorker.exists to SyncWorkerManager 2024-12-05 11:24:19 +01:00
Ricki Hirner
53bc5a6641 Update dependencies, AGP, gradle 2024-12-04 11:49:36 +01:00
Arnau Mora
9e060f6651 [Push] Allow UP distributor selection (#1146)
* Added UP distributor selector

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

* Load distributor selection from UnifiedPush

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

* Moved logic to model

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

* Added UP distributor selector

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

* Load distributor selection from UnifiedPush

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

* Moved logic to model

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

* Updated usages

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

* Got rid of stateIn

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

* Code cleanup and auto-selection

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

* Cleanup

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

* Code cleanup

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

* Added push distributor disabling

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

* Added push distributor selection dialog preview

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

* Update strings

* Minor changes

* Got rid of push endpoint

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

* Got rid of automatic push distributor selection

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

* Typo

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

* Moved init to the bottom

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

* MutableStateFlow: use setValue instead of emit

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-12-04 11:38:49 +01:00
Ricki Hirner
cc8fc4734f Move SyncUtils to SyncWorkerManager 2024-12-02 21:57:20 +01:00
Ricki Hirner
0733fef213 AppSettingsModel: clearly identify task app-related entries 2024-11-30 11:34:40 +01:00
Ricki Hirner
f977cc01eb Update dependencies (including ical4android) 2024-11-30 11:20:56 +01:00
Arnau Mora
30dc2cb221 Use specific help URL from current login type (originally in bitfireAT/davx5#611)
---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-29 16:48:26 +01:00
Ricki Hirner
fc6b605693 Remove unused drawables 2024-11-29 16:23:56 +01:00
Sunik Kupfer
4f1176fd99 Don't show app intro again after recomposition (#1142)
* Remember whether we showed the app intro already

* Add comment
2024-11-27 22:53:54 +01:00
Sunik Kupfer
4ff7ff8746 Extract sync framework interaction to separate class (#1137)
* Add SyncFrameworkIntegration class

* Use SyncFrameworkIntegration class

* Update kdoc

* Fix test

* Fix isSyncable check

* Update kdoc

* Remove singleton requirement

* Extract anonymous method setting the content trigger
2024-11-25 15:33:25 +01:00
Ricki Hirner
2f26c6c365 Fetch translations from Transifex 2024-11-24 14:53:37 +01:00
Ricki Hirner
d8bff41bc4 Version bump to 4.4.4 2024-11-24 14:51:32 +01:00
Ricki Hirner
878e2bb3ad Fetch translations from Trasifex 2024-11-22 17:16:19 +01:00
Ricki Hirner
dc1c72cdd3 Version bump to 4.4.4-rc.1 2024-11-22 17:14:36 +01:00
Arnau Mora
1dc7f3de64 Use timezone ID instead of full VTIMEZONE in DB (#1104)
* Migrated to using timezone id directly

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

* added tests for collection timezone

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

* Fixed calendar definition

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

* Added missing line break

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

* Unnecessary forced null

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

* Added ksp.incremental

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

* Version is now 16

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

* Comments cleanup

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

* Added automatic migrations tests

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

* Renamed block to assertionsBlock

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

* Added comment

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

* Got rid of extra comments

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

* Got rid of PR url

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

* Added column drop with try-catch

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

* Added `SdkSuppress`

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

* Added version check for column drop

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

* Included SDK 34

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

* Commit recover

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

* DB tests: inject Context

* DB migration tests: validate in target schema version; manual migration definitions: use new Kotlin syntax

* Use auto-migration instead of manual migration.

- No manual DROP COLUMN required.
- Added support for migration of unparseable VTIMEZONE.

* Update ical4android

* Service detection: ask for calendar default timezone (+ ID)

* Remove dropColumn because we don't need it for the current migration.

Leave the v4 -> v5 migration as it is.

* KDoc

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-22 17:13:57 +01:00
Ricki Hirner
d20c613044 Update dependencies, including ical4android (closes bitfireAT/davx5#625) 2024-11-22 16:56:13 +01:00
Ricki Hirner
fe8eabce1b Show sync error notification even with big local resource (#1139)
Sync error notification: truncate local info
2024-11-22 00:46:48 +01:00
Ricki Hirner
b5790bfd09 Update dependencies and gradle wrapper 2024-11-20 16:11:40 +01:00
Arnau Mora
9e7de1c8ca Enforced edge-to-edge (#1078)
* Enforced edge-to-edge

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

* Fixed bottom padding

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

* Recovered ime padding

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

* Updated theme colors for intro

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

* Always using light primary color in intro

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

* Simplified settings

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

* Added disabling of top padding

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

* Improved syntax

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

* Fixed padding

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

* Add comments

* Use default fallback colors for status/system bar instead of transparent

- With transparent, system bar buttons are white on very-light-grey

* Use safeContentPadding for intro

* Use scrim color from theme

* IntroPage: correctly consume insets; Assistant: KDoc/example

* Branding box extends to upper edge

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

* Set correct navigation bar color, if applicable

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

* Let content shine through navigation bar / especially for lists like settings

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

* Brand background is no longer padded

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

* Window insets are now consumed by AppTheme

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

* Fixed padding

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

* Do not consume insets

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

* Move content

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

* Enabled E2E in intro

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-11-20 13:05:09 +01:00
Ricki Hirner
15d2072f16 Version bump to 4.4.4-alpha.1 2024-11-19 19:55:41 +01:00
Ricki Hirner
0f4e48ad4d Move local collection management from companion objects to LocalDataStore (#1125)
* [WIP] Move local collection management from companion objects to LocalDataStore

* Move Syncer.getLocalCollections to LocalDataStore.getAll()

- note that things like the "sync_enabled" Android calendar flag are not supported and always set to true

* Minor changes

* Implement LocalJtxCollectionStore

* Implement LocalTaskListStore

* Fix tests

* Drop initialUserData

* Address book read-only applies to entries of address book itself, so moving to LocalAddressBook

* KDoc, shouldBeReadOnly

* Test accountName

* Remove obsolete address book factory

* Test create address book

* Test createAccount

---------

Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2024-11-18 12:46:14 +01:00
Ricki Hirner
41075e442c Push: fix expiration XML element in push-register message 2024-11-17 14:47:41 +01:00
Ricki Hirner
3a16b5ca3f [Push] Handle subscription expiration (#1131)
* Store subscription expiration in DB

* Regularly run PushRegistrationWorker, if needed

* Skip re-registering subscriptions that are not about to expire

* Add back-off for PushRegistrationWorkerManager

* Request expiration in 3 days

* Show expiration in UI, timestamps in seconds

* Fix tests
2024-11-17 14:39:22 +01:00
Sunik Kupfer
32925dc18b Set or update read-only flag when address books are renamed (#1124)
* Always evaluate read only

* Extract read only evaluation to companion method

* Extract read only evaluation to companion method

* Add test

* Always pass forceReadOnly flag

* Minor KDoc changes

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-07 11:45:27 +01:00
Sunik Kupfer
cf15dd3e0e Set contacts provider settings when address books are renamed or update (#1123)
Set contacts provider settings when address books are renamed and updated
2024-11-06 22:10:44 +01:00
Ricki Hirner
3317a8d355 Move dirty verifier logic to ContactDirtyVerifier (#1122)
* Move dirty verifier logic to ContactDirtyVerifier

* KDoc

* KDoc, fix contactDataHashCode

* Fix tests

* Add KDoc
2024-11-06 12:05:09 +01:00
Ricki Hirner
154d1e6bc8 AccountSettingsMigrations: fix comment, require tests (#1116)
AccountSettingsMigration: fix comment, require tests
2024-11-01 15:59:14 +01:00
Ricki Hirner
1a19d5cd17 Update AGP 2024-11-01 11:38:58 +01:00
Ricki Hirner
b721e83377 Update dependencies 2024-10-31 09:55:19 +01:00
Ricki Hirner
f69533b049 Remove obsolete permissions for SDK level 22 (min SDK = 24) 2024-10-28 11:51:59 +01: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
Ricki Hirner
6df0925e50 Version bump to 4.4.2 2024-08-06 13:31:14 +02:00
Ricki Hirner
f7ee1ea931 [UI] Show push support status in collection details (#961)
* Collection details: show WebPush support

* Collection view: show Push subscription time
2024-08-06 13:29:31 +02:00
Ricki Hirner
16731d3a5a Suppress dnsjava warning because of missing Context (#959) 2024-08-06 12:39:57 +02:00
Ricki Hirner
54e09acca3 Don't generate/let Gradle user cache grow in main branch 2024-08-06 11:26:46 +02:00
Ricki Hirner
46698a76b5 Fetch translations from Transifex 2024-08-05 17:41:06 +02:00
Ricki Hirner
e26a8519ff Version bump to 4.4.2-rc.1 2024-08-05 17:40:06 +02:00
Arnau Mora
410c70a47d Passing collection directly (#927)
* Passing `collection` directly

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

* Fixed imports

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

* Fix tests

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

* Log sync time using repository; no need for account/service check anymore

* SyncManagerTest: don't write SyncStats to DB

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-08-05 17:24:46 +02:00
Sunik Kupfer
bf1bdfc8ab Generalize syncer (#907)
* Use address book contacts content provider provided by syncer

* Close any acquired content provider after sync

* Acquire/Check task provider provider as preparation step

* Provide sync arguments at syncer creation

* Acquire ContentProviderClient in syncer implementations

* Generalize sync algorithm in Syncer

* Use contacts authority for address books

* Generalize sync algorithm in CalendarSyncer

* Generalize sync algorithm in SyncerTest

* Generalize sync algorithm in TaskSyncer

* Rename preparation method and add an after sync method

* Generalize sync algorithm in JtxSyncer

* Generalize sync algorithm in AddressBookSyncer

* Use repositories instead of DAOs

* Replace deprecated log statements

* Use generic type for collection types

* Infer authorities when possible and pass only task authorities along

* No need to close TaskProvider explicitly

* Use colors only where needed

* Use provider with auto closable

* Get sync collections in syncer implementations

* Delete syncer test

* Pass provider through methods instead of using lateinit property

* Reorder constructor arguments

* Remove trailing commas

* Remove obsolete undocumented conditional

* Reorder methods

* Reorder methods

* Abort sync when preparations fail

* Drop obsolete permission check

* Use generics for url and delete

* Use generic for update

* Use generic for syncCollection

* Rename create to createCollection for consistency

* Revert "Rename create to createCollection for consistency"

This reverts commit 0fee4fe7fcf3ec8ef965c9a2e0db991a1bbbbcf7.

* Revert "Use generic for syncCollection"

This reverts commit ae129fd17e06146a1e9f8631e3cdbd2dc0a4db06.

* Revert "Use generic for update"

This reverts commit 42dd665851ba83a75bb498b98bd56624e2b09647.

* Revert "Use generics for url and delete"

This reverts commit 7ae1425039656d4a9937628ca1799ce8c59ceebb.

* Move delete() to LocalCollection

* Move url to LocalCollection

* Fix local test collection

* Minor changes

* Minor changes
- make sync private
- use to autoclose provider

* Query for sync collections once only

* Add KDoc and update comments

* Make property a local variable

* Update KDoc

* Add back ose conditional

* Remove blank line at beginning of method

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-08-05 17:22:07 +02:00
Arnau Mora
5cbbfb39aa Update dav4jvm for better logging (#956)
* Upgrade dav4jvm

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

* Using `XmlReader`

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-08-05 16:42:24 +02:00
Ricki Hirner
62d5a21d05 Update ical4android, specify ical4j log level (#946)
* Update ical4android, specify ical4j log level (was previously in ical4android)

* Add "dependencies" label for auto-generated release notes
2024-07-28 16:54:01 +02:00
Ricki Hirner
2a7cf1ae17 Fix R8 rules 2024-07-28 14:34:55 +02:00
Ricki Hirner
1062eaa58a Version bump to 4.4.2-beta.1 2024-07-28 13:54:03 +02:00
Ricki Hirner
69bde87589 WebDAV mounts: fix quota view (#945)
* Refresh quota for newly created mounts

* WebDAV mounts screen: take quota from root document
2024-07-28 13:53:39 +02:00
Ricki Hirner
4111fe08d2 Drop userAgent build configuration variable 2024-07-28 12:36:59 +02:00
Ricki Hirner
19fb969040 Drop userAgent build configuration variable 2024-07-28 11:20:25 +02:00
Ricki Hirner
74a22cd24d Update dependencies 2024-07-28 11:14:53 +02:00
Ricki Hirner
4e264076d1 Amend PR template 2024-07-26 14:50:21 +02:00
Ricki Hirner
c3fe1b04e5 Backport minor changes from non-ose (#941)
Login: correctly combine input flows for account details UI state (backport bitfireAT/davx5#575)
2024-07-26 12:04:19 +02:00
Ricki Hirner
342314363b Simplify InitCalendarProviderRule (#940)
Simplify InitCalendarProviderRule
2024-07-25 22:56:07 +02:00
Ricki Hirner
d0358a9980 Use test account type (without sync adapters) for instrumented tests (bitfireAT/davx5#601)
* Use test account type (without sync adapters as side effects) for instrumented tests

* Provide standardized way to get test account; re-enable LocalAddressBookTest
2024-07-25 20:57:23 +02:00
Ricki Hirner
fbcf6996ad Don't access DB in UnifiedPushReceiver main thread 2024-07-24 17:43:58 +02:00
Ricki Hirner
cf994ee82e Move UrlUtils methods to DavUtils 2024-07-24 17:15:57 +02:00
Ricki Hirner
9880dd5158 Move TaskUtils to TasksAppManager (closes #937) 2024-07-24 16:39:30 +02:00
Ricki Hirner
8e7d289971 Tests: don't create account as long as Hilt is not ready (#939) 2024-07-24 15:13:34 +02:00
Ricki Hirner
d4c05b9282 NotificationRegistry: enforce creation of channels before they can be accessed 2024-07-24 15:13:06 +02:00
Ricki Hirner
768f462549 Use AccountRepository to get list of accounts (#938)
* Use AccountRepository to get list of accounts

* No AndroidEntryPoint in (Glance) widget
2024-07-24 14:50:48 +02:00
Ricki Hirner
cbd9a55c15 Replace some non-injected loggers 2024-07-24 09:53:31 +02:00
dependabot[bot]
4e496265e4 Bump dnsjava:dnsjava from 3.5.3 to 3.6.0 (#933)
Bumps [dnsjava:dnsjava](https://github.com/dnsjava/dnsjava) from 3.5.3 to 3.6.0.
- [Release notes](https://github.com/dnsjava/dnsjava/releases)
- [Changelog](https://github.com/dnsjava/dnsjava/blob/master/Changelog)
- [Commits](https://github.com/dnsjava/dnsjava/compare/v3.5.3...v3.6.0)

---
updated-dependencies:
- dependency-name: dnsjava:dnsjava
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-23 22:08:16 +02:00
Arnau Mora
73d0b63705 Debug info: Sync interval "0 min" when actually -1 (manual) (#928)
Added check for only manual sync

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-07-23 19:08:06 +02:00
Ricki Hirner
a361888d94 Version bump to 4.4.2-alpha.2 2024-07-23 01:11:39 +02:00
Ricki Hirner
907b38fd6a Better verbose log formatting 2024-07-22 00:18:52 +02:00
Ricki Hirner
bf0e169cf1 Remove obsolete Logger object (#930) 2024-07-21 23:32:45 +02:00
Ricki Hirner
fd2b3f0018 Refactor SettingsManager/SettingsProvider to use DI properly (bitfireAT/davx5#599)
* Refactor SettingsManager/SettingsProvider to use DI

* Fix tests

* Remove BaseDefaultsProvider

* Fix gplay tests
2024-07-20 21:47:15 +02:00
Ricki Hirner
6217582677 Debug builds: add StrictMode for ThreadPolicy, don't setup crash handler 2024-07-20 18:51:57 +02:00
Ricki Hirner
d03dc1f37d Remove unnecessary SyncComponent 2024-07-20 18:40:42 +02:00
Ricki Hirner
d33e4dcb23 Update dependencies 2024-07-20 18:35:31 +02:00
Ricki Hirner
f4c02d4ab6 [DI] Use @Inject lateinit var for abstract classes (#929)
* Use @Inject lateinit var for abstract classes (subclasses have @Inject constructor)

* Fix tests
2024-07-20 18:19:04 +02:00
Arnau Mora
fafa358dd8 DB: some missing indices (#890)
* Added index for `ownerId`

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

* Increased version number

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

* Increased version number

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

* Added version

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

* Added pushTopic as index

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

* Moved index

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

* Changed uniqueness

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

* Changed indexing method

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

* Moved `parentId` to independent index

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-07-20 17:35:39 +02:00
Ricki Hirner
d5d6592ae2 SyncWorker: use Provider for lazy injection (#925)
* SyncWorker: use Provider for lazy injection

* Use Provider to lazyily inject SyncAdapter
2024-07-19 18:09:20 +02:00
Ricki Hirner
ac48e65b1a Remove deprecated code / annotate as deprecated 2024-07-19 17:41:47 +02:00
Ricki Hirner
26f95db62a Re-factor Notifications and various Utils to DI (#924)
* [WIP] Use NotificationRegistry to post notifications

* Replace NotificationUtils by NotificationRegistry

* Re-factor SyncConditions; move tests to default location

* Describe notification channels
2024-07-19 17:10:29 +02:00
Ricki Hirner
5c4d697767 WebDAV cache: re-factor using Guava and Hilt (#921)
* Use per-resource Guava cache with weak keys and values as WebDAV page cache

* Replace ExtendedLruCache by Guava cache

* Use injected logger in DavDocumentsProvider

* Use DI for RandomAccessCallback, move Wrapper to outer class

* Fix tests

* Use Hilt for more classes

* Use Guava LoadingCache as page cache

* Fix tests

* Minor code change
2024-07-19 11:59:10 +02:00
Ricki Hirner
59a57fc40a Update dnsjava, use desugaring with nio, refactor DNS resolving (#917)
* Update dnsjava, use desugaring with nio, refactor DNS resolving

* KDoc

* Rewrite bestSRVRecord test

* Add pathsFromTXTRecords tests

* Add desugaring note

* Rename @AssistedFactory method to "create"

* testBestSRVRecord_MultipleRecords_Priority_Same: broaden range for distribution
2024-07-18 11:09:00 +02:00
Ricki Hirner
50c13e5b6d Install uncaught exception handler in a separate startup plugin (bitfireAT/davx5#597) 2024-07-18 10:21:31 +02:00
Ricki Hirner
3a38a06302 Version bump to 4.4.2-alpha.2 2024-07-17 12:41:50 +02:00
Ricki Hirner
c489002f5c Create interface for startup plugins (#915)
* Create StartupPlugin interface, implement with TasksAppWatcher (don't run during tests)

* KDoc
2024-07-16 14:45:40 +02:00
Ricki Hirner
7cbc9bd4f5 Sync: take URL from DB (Collection) instead of making a detour over other places 2024-07-16 12:14:10 +02:00
Ricki Hirner
1fd65a4d42 Use DI for AccountSettings and Android tests (#911)
* Use DI for AccountSettings

* AccountSettings: clarify different account variables

* Fix tests

* Inject @ApplicationContext instead of Application

* Add Hilt rule to various tests

* Fix tests

* Consequently use injected @ApplicationContext in Android tests; fix Hilt rules
2024-07-15 19:39:21 +02:00
Sunik Kupfer
b3cc24e4be Fix: Syncer synchronizes only once (#910)
* Use a separate HashMap to determine new remote collections

* Close provider after sync
2024-07-15 11:00:34 +02:00
Ricki Hirner
556741ae1e Get rid of Apache Commons (#901)
* Replace StringUtils

* Replace DateFormatUtils and NumberUtils

* Fix tests

* Replace ReflectionToStringBuilder

* Rewrite MemoryCookieStore to get rid of MultiMap

* Get rid of org.apache.commons.lang3.exception.ExceptionUtils

* Replace org.apache.commons.collections4.CollectionUtils

* Replace DigestUtils

* Replace ContextedException by SyncException

* Fix HttpClientTest

* SyncManager: remove obsolete code

* Fix StringUtilsTest

* DiskCache: add hit/miss logging

* DebugInfoModel: simplify PrintWriter generation
2024-07-14 10:22:00 +02:00
Ricki Hirner
b1bcf32535 Update AGP 2024-07-13 18:44:53 +02:00
Ricki Hirner
be43d360ba Refactor logging (#906)
* LogcatHandler: log source class as tag and not in the text, remove max length

* Separate LogManager and Logger usage

* Improve StringHandler for DavResourceFinder

* Tests, KDoc
2024-07-13 17:37:07 +02:00
Ricki Hirner
0adceb64ec Squashed commit of the following:
commit 60219aafc7
Author: Ricki Hirner <hirner@bitfire.at>
Date:   Thu Jul 11 23:59:54 2024 +0200

    Version bump to 4.4.1.1

commit 209fdf3e7c
Author: Ricki Hirner <hirner@bitfire.at>
Date:   Thu Jul 11 23:37:37 2024 +0200

    AccountSettings v16: re-enqueue periodic sync workers with correct class name (bitfireAT/davx5#593)
2024-07-12 12:30:19 +02:00
Ricki Hirner
5ebef30abb Tasks screen: adapt padding and recommendation (#898)
Tasks screen: adapt padding and text
2024-07-11 15:47:56 +02:00
Sunik Kupfer
3d65afbf8f Provide collection to SyncManager (#881)
* Provide collection to CalendarSyncManager

* Provide collection to ContactsSyncManager

* Provide collection to JtxSyncManager

* Provide collection to TasksSyncManager

* Provide collection to SyncManager

* Fix test

* Minor changes

* Add kdoc

* Minor changes

- added KDoc
- SyncManager can never handle address book authority (content
  authority for contacts is contacts authority)
- moved @UsesThreadContextClassLoader to respective sync manager

* Mock collection in test

* Add comments separating the sync process into separate steps

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-07-11 14:09:35 +02:00
Sunik Kupfer
51f01b215c Remove address books sync authority and content provider (#877)
* Drop address book provider

* Drop address book sync adapter service

* Replace address books authority with contacts authority

* Update kdoc

* Revert "Update kdoc"

This reverts commit dfb14d466f6c58d9422e59c770e0a5348a497b7d.

* Revert "Replace address books authority with contacts authority"

This reverts commit 0e15bf11b3235dfbc38696741100210fbe497dbd.

* Don't enable addressbook sync for main account

* Acquire content provider in Syncer

* Use contacts authority instead of address book authority when acquiring content provider

* Set default sync interval for address book authority again

* Minor re-ordering of lines

* Add comment, rename variable

* Move SyncAdapterServices out of adapter package

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-07-11 11:17:09 +02:00
Arnau Mora
c02bf942e4 Replaced "Create" by "Add" when creating accounts (#892)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2024-07-08 11:11:21 +02:00
Ricki Hirner
d7025d4e9e Use proper DI for Syncer implementations (#889) 2024-07-04 22:21:20 +02:00
Ricki Hirner
56f7b4bbc5 Replace Apache Commons by native calls/Guava (#883)
* Replace Commons IO by native calls/Guava

* Drop other Apache Commons

* Fix tests
2024-07-04 16:00:29 +02:00
700 changed files with 41329 additions and 23134 deletions

8
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
# See https://docs.github.com/de/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# For combination with "Require review from code owners" for main-ose branch.
# Dependabot
gradle/** @bitfireAT/app-dev
# everything else
* @rfc2822

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: DAVx⁵ Community Support
url: https://github.com/bitfireAT/davx5-ose/discussions

View File

@@ -1,5 +1,6 @@
name: Qualified Bug Report
description: "[Developers only] For qualified bug reports. (Use Discussions if unsure.)"
description: "For qualified bug reports. (Use Discussions if unsure.)"
type: bug
labels: ["bug"]
body:
- type: checkboxes

View File

@@ -1,5 +1,6 @@
name: Qualified Feature Request
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
description: "For qualified feature requests. (Use Discussions if unsure.)"
type: feature
labels: ["enhancement"]
body:
- type: checkboxes

32
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
version: 2
updates:
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
# Workflow files stored in the default location of `.github/workflows`
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "[CI] "
labels:
- "github_actions"
- "dependencies"
groups:
ci-actions:
patterns: ["*"]
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
labels: # don't create "java" label (default for gradle ecosystem)
- "dependencies"
groups:
app-dependencies:
patterns: ["*"]
ignore:
# dependencies without semantic versioning
- dependency-name: "com.github.bitfireat:cert4android"
- dependency-name: "com.github.bitfireat:dav4jvm"
- dependency-name: "com.github.bitfireat:synctools"

View File

@@ -1,4 +1,6 @@
Please delete this paragraph and other repeating text (like the examples) after reading and before submitting the PR.
The PR should be in _Draft_ state during development. As soon as it's finished, it should be marked as _Ready for review_ and a reviewer should be chosen.
See also: [Writing A Great Pull Request Description](https://www.pullrequest.com/blog/writing-a-great-pull-request-description/)

3
.github/release.yml vendored
View File

@@ -12,6 +12,9 @@ changelog:
- title: Refactoring
labels:
- refactoring
- title: Dependencies
labels:
- dependencies
- title: Other changes
labels:
- "*"

View File

@@ -8,6 +8,7 @@ on:
branches: [ main-ose ]
schedule:
- cron: '22 10 * * 1'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
@@ -21,38 +22,29 @@ jobs:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'java' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-java@v4
uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
java-version: 21
- uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true # gradle user home cache is generated by test jobs
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
languages: java-kotlin
build-mode: manual # autobuild uses older JDK
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
#- name: Autobuild
# uses: github/codeql-action/autobuild@v2
- name: Build
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
- name: Build # we must not use build cache here
run: ./gradlew --no-daemon --configuration-cache app:assembleDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -0,0 +1,24 @@
name: Dependency Submission
on:
push:
branches: [ 'main-ose' ]
permissions:
contents: write
jobs:
dependency-submission:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'

View File

@@ -1,55 +0,0 @@
name: Dependent Issues
on:
issues:
types:
- opened
- edited
- closed
- reopened
pull_request_target:
types:
- opened
- edited
- closed
- reopened
# Makes sure we always add status check for PRs. Useful only if
# this action is required to pass before merging. Otherwise, it
# can be removed.
- synchronize
# Schedule a daily check. Useful if you reference cross-repository
# issues or pull requests. Otherwise, it can be removed.
schedule:
- cron: '19 9 * * *'
permissions: write-all
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: z0al/dependent-issues@v1
env:
# (Required) The token to use to make API calls to GitHub.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# (Optional) The token to use to make API calls to GitHub for remote repos.
GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }}
with:
# (Optional) The label to use to mark dependent issues
# label: dependent
# (Optional) Enable checking for dependencies in issues.
# Enable by setting the value to "on". Default "off"
check_issues: on
# (Optional) A comma-separated list of keywords. Default
# "depends on, blocked by"
keywords: depends on, blocked by
# (Optional) A custom comment body. It supports `{{ dependencies }}` token.
comment: >
This PR/issue depends on:
{{ dependencies }}

View File

@@ -19,19 +19,19 @@ jobs:
discussions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
java-version: 21
- uses: gradle/actions/setup-gradle@v5
- name: Prepare keystore
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
- name: Build signed package
# Make sure that caches are disabled to generate reproducible release builds
run: ./gradlew --no-build-cache --no-configuration-cache --no-daemon app:assembleRelease
# Use build cache to speed up building of build variants, but clean caches from previous tests before
run: ./gradlew --build-cache --configuration-cache --no-daemon app:clean app:assembleRelease
env:
ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }}
@@ -45,4 +45,3 @@ jobs:
files: app/build/outputs/apk/ose/release/*.apk
fail_on_unmatched_files: true
generate_release_notes: true
discussion_category_name: Announcements

View File

@@ -2,80 +2,135 @@ name: Development tests
on:
push:
branches:
- '*'
- 'main-ose'
pull_request:
concurrency:
group: test-dev-${{ github.ref }}
cancel-in-progress: true
# We provide a remote gradle build cache. Take the settings from the secrets and enable
# configuration and build cache for all gradle jobs.
#
# Note: The secrets are not available for forks and Dependabot PRs.
env:
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
jobs:
compile:
name: Compile and cache
name: Compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
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
- uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
gradle-home-cache-cleanup: true # clean up unused files
dependency-graph: generate-and-submit # submit Github Dependency Graph info
dependency-graph-continue-on-failure: false
cache-read-only: false # allow branches to update their configuration cache
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:assembleDebug
- name: Cache Android environment
uses: actions/cache@v5
with:
path: ~/.config/.android # needs to be cached so that configuration cache can work
key: android-${{ hashFiles('app/build.gradle.kts') }}
test:
- name: Compile
run: ./gradlew app:compileOseDebugSource
# Cache configurations for the other jobs (including assemble for CodeQL)
- name: Populate configuration cache
run: |
./gradlew --dry-run app:assembleDebug
./gradlew --dry-run app:lintOseDebug
./gradlew --dry-run app:testOseDebugUnitTest
./gradlew --dry-run app:virtualOseDebugAndroidTest
unit_tests:
needs: compile
name: Tests without emulator
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
java-version: 21
- uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Run lint
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
- name: Restore Android environment
uses: actions/cache/restore@v5
with:
path: ~/.config/.android
key: android-${{ hashFiles('app/build.gradle.kts') }}
# generates the build caches because it uses more gradle dependencies
test_on_emulator:
- name: Lint checks
run: ./gradlew app:lintOseDebug
- name: Unit tests
run: ./gradlew app:testOseDebugUnitTest
instrumented_tests:
needs: compile
name: Tests with emulator
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
java-version: 21
- uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Restore Android environment
uses: actions/cache/restore@v5
with:
path: ~/.config/.android
key: android-${{ hashFiles('app/build.gradle.kts') }}
# gradle and Android SDK often take more space than what is available on the default runner.
# We try to free a few GB here to make gradle-managed devices more reliable.
- name: Free some disk space
uses: jlumbroso/free-disk-space@main
with:
android: false # we need the Android SDK
large-packages: false # apt takes too long
swap-storage: false # gradle needs much memory
- name: Restore AVD
id: restore-avd
uses: actions/cache/restore@v5
with:
path: ~/.config/.android/avd # where AVD is stored
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
# Enable virtualization for Android emulator
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cache AVD
uses: actions/cache@v4
with:
path: ~/.config/.android/avd
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
- name: Instrumented tests
run: ./gradlew app:virtualOseDebugAndroidTest
- name: Run device tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck
- name: Cache AVD
uses: actions/cache/save@v5
if: steps.restore-avd.outputs.cache-hit != 'true'
with:
path: ~/.config/.android/avd # where AVD is stored
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there

4
.gitignore vendored
View File

@@ -16,10 +16,6 @@
bin/
gen/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties

9
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<option name="RIGHT_MARGIN" value="180" />
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/copyright/LICENSE.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
<option name="myName" value="LICENSE" />
</copyright>
</component>

3
.idea/copyright/profiles_settings.xml generated Normal file
View File

@@ -0,0 +1,3 @@
<component name="CopyrightManager">
<settings default="LICENSE" />
</component>

View File

@@ -1,30 +0,0 @@
[main]
host = https://www.transifex.com
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
[o:bitfireAT:p:davx5:r:app]
file_filter = app/src/main/res/values-<lang>/strings.xml
source_file = app/src/main/res/values/strings.xml
source_lang = en
type = ANDROID
minimum_perc = 20
resource_name = App strings (all flavors)
# Attention: fastlane directories are like "en-us", not "en-rUS"!
[o:bitfireAT:p:davx5:r:metadata-short-description]
file_filter = fastlane/metadata/android/<lang>/short_description.txt
source_file = fastlane/metadata/android/en-US/short_description.txt
source_lang = en
type = TXT
minimum_perc = 100
resource_name = Metadata: short description
[o:bitfireAT:p:davx5:r:metadata-full-description]
file_filter = fastlane/metadata/android/<lang>/full_description.txt
source_file = fastlane/metadata/android/en-US/full_description.txt
source_lang = en
type = TXT
minimum_perc = 100
resource_name = Metadata: full description

14
AUTHORS
View File

@@ -1,11 +1,7 @@
# This is the list of significant contributors to DAVx5.
#
# This does not necessarily list everyone who has contributed work.
# To see the full list of contributors, see the revision history in
# source control.
You can view the list of people who have contributed to the code base in the version control history:
https://github.com/bitfireAT/davx5-ose/graphs/contributors
Ricki Hirner (bitfire.at)
Bernhard Stockmann (bitfire.at)
Translators are not mentioned in the history explicitly.
The list of translators can be found in the About screen.
Sunik Kupfer (bitfire.at)
Patrick Lang (techbee.at)
Every contribution is welcome. There are many other forms of contributing besides writing code!

View File

@@ -14,24 +14,11 @@ If you send us a pull request, our CLA bot will ask you to sign the
Contributor's License Agreement so that we can use your contribution.
# Copyright
# Copyright notice
Make sure that every file that contains significant work (at least every code file)
starts with the copyright header:
```
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
```
You can set this in Android Studio:
1. Settings / Editor / Copyright / Copyright Profiles
2. Paste the text above (without the stars).
3. Set Formatting so that the preview exactly looks like above; one blank line after the block.
4. Set this copyright profile as the default profile for the project.
5. Apply copyright: right-click in file tree / Update copyright.
starts with the copyright header. Android Studio should do so automatically because the
configuration is stored in the repository (`.idea/copyright`).
# Style guide
@@ -110,8 +97,3 @@ Test classes should be in the appropriate directory (see existing tests) and in
tested class. Tests are usually be named like `methodToBeTested_Condition()`, see
[Test apps on Android](https://developer.android.com/training/testing/).
# Authors
If you make significant contributions, feel free to add yourself to the [AUTHORS file](AUTHORS).

View File

@@ -1,9 +1,9 @@
[![Website](https://img.shields.io/website?style=flat-square&up_color=%237cb342&url=https%3A%2F%2Fwww.davx5.com)](https://www.davx5.com/)
[![F-Droid](https://img.shields.io/f-droid/v/at.bitfire.davdroid?style=flat-square)](https://f-droid.org/packages/at.bitfire.davdroid/)
[![License](https://img.shields.io/github/license/bitfireAT/davx5-ose?style=flat-square)](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
[![Follow @davx5app@fosstodon.org](https://img.shields.io/mastodon/follow/109598783742737223?domain=https%3A%2F%2Ffosstodon.org&style=flat-square)](https://fosstodon.org/@davx5app)
[![Development tests](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml/badge.svg)](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml)
[![Website](https://img.shields.io/website?style=flat-square&up_color=%237cb342&url=https%3A%2F%2Fwww.davx5.com)](https://www.davx5.com/)
[![License](https://img.shields.io/github/license/bitfireAT/davx5-ose?style=flat-square)](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
[![F-Droid](https://img.shields.io/f-droid/v/at.bitfire.davdroid?style=flat-square)](https://f-droid.org/packages/at.bitfire.davdroid/)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/bitfireAT/davx5-ose/total?label=GitHub%20downloads)
![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png)
@@ -11,8 +11,10 @@
DAVx⁵
========
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
comprehensive information about DAVx⁵, including a list of services it has been tested with.
> [!IMPORTANT]
> Please see the [DAVx⁵ Web site](https://www.davx5.com) for
> comprehensive information about DAVx⁵, including a list of services it has been tested with,
> a manual and FAQ.
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
@@ -26,8 +28,7 @@ Parts of DAVx⁵ have been outsourced into these libraries:
* [cert4android](https://github.com/bitfireAT/cert4android) custom certificate management
* [dav4jvm](https://github.com/bitfireAT/dav4jvm) WebDAV/CalDav/CardDAV framework
* [ical4android](https://github.com/bitfireAT/ical4android) iCalendar processing and Calendar Provider access
* [vcard4android](https://github.com/bitfireAT/vcard4android) vCard processing and Contacts Provider access
* [synctools](https://github.com/bitfireAT/synctools) iCalendar/vCard/Tasks processing and content provider access
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
or [purchasing it](https://www.davx5.com/download).**

View File

@@ -1,46 +1,44 @@
/***************************************************************************************************
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
*/
plugins {
alias(libs.plugins.mikepenz.aboutLibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries.android)
}
// Android configuration
android {
compileSdk = 34
compileSdk = 36
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404010005
versionName = "4.4.1"
versionCode = 405090005
versionName = "4.5.9"
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
setProperty("archivesBaseName", "davx5-ose-$versionName")
base.archivesName = "davx5-$versionCode-$versionName"
minSdk = 24 // Android 7.0
targetSdk = 34 // Android 14
targetSdk = 36 // Android 16
buildConfigField("String", "userAgent", "\"DAVx5\"")
testInstrumentationRunner = "at.bitfire.davdroid.CustomTestRunner"
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(21)
}
}
compileOptions {
// enable because ical4android requires desugaring
// required for
// - dnsjava 3.x: java.nio.file.Path
// - ical4android: time API
isCoreLibraryDesugaringEnabled = true
}
@@ -50,7 +48,7 @@ android {
}
// Java namespace for our classes (not to be confused with Android package ID)
namespace = "at.bitfire.davdroid"
namespace = "com.davx5.ose"
flavorDimensions += "distribution"
productFlavors {
@@ -87,25 +85,28 @@ android {
}
lint {
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos", "NullSafeMutableLiveData")
}
packaging {
resources {
excludes += arrayOf("META-INF/*.md")
}
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
}
androidResources {
generateLocaleConfig = true
}
packaging {
resources {
// multiple (test) dependencies have LICENSE files at same location
merges += arrayOf("META-INF/LICENSE*")
}
}
@Suppress("UnstableApiUsage")
testOptions {
managedDevices {
localDevices {
create("virtual") {
device = "Pixel 3"
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
apiLevel = 34
systemImageSource = "aosp-atd"
}
@@ -118,19 +119,18 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
configurations {
configureEach {
// exclude modules which are in conflict with system libraries
exclude(module="commons-logging")
exclude(group="org.json", module="json")
// Groovy requires SDK 26+, and it's not required, so exclude it
exclude(group="org.codehaus.groovy")
aboutLibraries {
export {
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
excludeFields.add("generated")
}
}
dependencies {
// core
// app core
implementation(project(":core"))
// Kotlin / Android
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines)
coreLibraryDesugaring(libs.android.desugaring)
@@ -158,49 +158,62 @@ dependencies {
// Jetpack Compose
implementation(libs.compose.accompanist.permissions)
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
implementation(libs.compose.materialIconsExtended)
implementation(libs.compose.runtime.livedata)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.toolingPreview)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.materialIconsExtended)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.toolingPreview)
// Glance Widgets
implementation(libs.glance.base)
implementation(libs.glance.material)
implementation(libs.androidx.glance.base)
implementation(libs.androidx.glance.material3)
// Jetpack Room
implementation(libs.room.runtime)
implementation(libs.room.base)
implementation(libs.room.paging)
ksp(libs.room.compiler)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.base)
implementation(libs.androidx.room.paging)
ksp(libs.androidx.room.compiler)
// own libraries
implementation(libs.bitfire.cert4android)
implementation(libs.bitfire.dav4jvm) {
exclude(group="junit")
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
}
implementation(libs.bitfire.synctools) {
exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code
exclude(group = "junit")
}
implementation(libs.bitfire.ical4android)
implementation(libs.bitfire.vcard4android)
// third-party libs
implementation(libs.commons.collections)
@Suppress("RedundantSuppression")
implementation(libs.commons.io)
implementation(libs.commons.lang)
implementation(libs.commons.text)
@Suppress("RedundantSuppression")
implementation(libs.conscrypt)
implementation(libs.dnsjava)
implementation(libs.mikepenz.aboutLibraries)
implementation(libs.nsk90.kstatemachine)
implementation(libs.guava)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.mikepenz.aboutLibraries.m3)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)
implementation(libs.openid.appauth)
implementation(libs.unifiedpush)
implementation(libs.unifiedpush) {
// UnifiedPush connector seems to be using a workaround by importing this library.
// Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
// See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59
exclude(group = "com.google.crypto.tink", module = "tink")
}
implementation(libs.unifiedpush.fcm)
// force some versions for compatibility with our minSdk level (see version catalog for details)
implementation(libs.commons.codec)
implementation(libs.commons.lang)
// for tests
androidTestImplementation(libs.androidx.arch.core.testing)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.rules)
@@ -208,10 +221,13 @@ dependencies {
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.okhttp.mockwebserver)
androidTestImplementation(libs.room.testing)
testImplementation(libs.bitfire.dav4jvm)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.okhttp.mockwebserver)
}
testImplementation(libs.robolectric)
}

View File

@@ -1,59 +0,0 @@
# R8 usage for DAVx⁵:
# shrinking yes (only in release builds)
# optimization yes (on by R8 defaults)
# full-mode no (see gradle.properties)
# obfuscation no (open-source)
-dontobfuscate
-printusage build/reports/r8-usage.txt
# ez-vcard: keep all vCard properties/parameters (used via reflection)
-keep class ezvcard.io.scribe.** { *; }
-keep class ezvcard.property.** { *; }
-keep class ezvcard.parameter.** { *; }
# ical4j: keep all iCalendar properties/parameters (used via reflection)
-keep class net.fortuna.ical4j.** { *; }
# XmlPullParser
-keep class org.xmlpull.** { *; }
# DAVx + libs
-keep class at.bitfire.** { *; } # all DAVx code is required
# AGP 8.2 and 8.3 seem to remove this class, but ezvcard.io uses it. See https://github.com/bitfireAT/davx5/issues/499
-keep class javax.xml.namespace.QName { *; }
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Additional rules which are now required since missing classes can't be ignored in R8 anymore.
# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning]
-dontwarn com.android.org.conscrypt.SSLParametersImpl
-dontwarn com.github.erosb.jsonsKema.** # ical4j
-dontwarn com.google.errorprone.annotations.**
-dontwarn com.sun.jna.** # dnsjava
-dontwarn groovy.**
-dontwarn java.beans.Transient
-dontwarn javax.cache.** # ical4j
-dontwarn javax.naming.NamingException # dnsjava
-dontwarn javax.naming.directory.** # dnsjava
-dontwarn junit.textui.TestRunner
-dontwarn lombok.** # dnsjava
-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.codehaus.groovy.**
-dontwarn org.joda.**
-dontwarn org.jparsec.** # ical4j
-dontwarn org.json.*
-dontwarn org.jsoup.**
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn org.xmlpull.**
-dontwarn sun.net.spi.nameservice.NameService
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor

1
app/src/.gitignore vendored
View File

@@ -1 +0,0 @@
espressoTest

View File

@@ -1,17 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.testing.HiltTestApplication
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, name: String, context: Context) =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View File

@@ -1,20 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
import android.util.Xml
import at.bitfire.dav4jvm.XmlUtils
import org.junit.Assert.assertTrue
import org.junit.Test
class Dav4jvm {
@Test
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
val parser = XmlUtils.newPullParser()
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
}
}

View File

@@ -1,96 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.Manifest
import android.accounts.Account
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import androidx.annotation.RequiresApi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* 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!
*
* If you run tests manually, just make sure to ignore the first run after the calendar
* provider has been accessed the first time.
*/
class InitCalendarProviderRule private constructor(): TestRule {
companion object {
fun getInstance() = RuleChain
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
.around(InitCalendarProviderRule())
}
override fun apply(base: Statement, description: Description): Statement {
Logger.log.info("Initializing calendar provider before running ${description.displayName}")
return InitCalendarProviderStatement(base)
}
class InitCalendarProviderStatement(val base: Statement): Statement() {
override fun evaluate() {
if (Build.VERSION.SDK_INT < 31)
Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
initCalendarProvider()
base.evaluate()
}
private fun initCalendarProvider() {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val uri = AndroidCalendar.create(account, provider, ContentValues())
val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
try {
// single event init
val normalEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
normalLocalEvent.add()
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
// recurring event init
val recurringEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
}
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
localRecurringEvent.add()
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
} finally {
calendar.delete()
}
}
}
}

View File

@@ -1,22 +0,0 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.push.PushRegistrationWorker
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
import dagger.hilt.testing.TestInstallIn
interface TestModules {
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
)
abstract class FakePushRegistrationWorkerModule {
// Provides empty set of listeners
@Multibinds
abstract fun defaultOnChangeListeners(): Set<DavCollectionRepository.OnChangeListener>
}
}

View File

@@ -1,50 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import android.content.Context
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
class AppDatabaseTest {
val TEST_DB = "test"
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Rule
@JvmField
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(), // no auto migrations until v8
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun testAllMigrations() {
// DB schema is available since version 8, so create DB with v8
helper.createDatabase(TEST_DB, 8).close()
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
// manual migrations
.addMigrations(*AppDatabase.migrations)
// auto-migrations that need to be specified explicitly
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
.build()
try {
// open (with version 8) + migrate (to current version) database
db.openHelper.writableDatabase
} finally {
db.close()
}
}
}

View File

@@ -1,155 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class CollectionTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun shutDown() {
httpClient.close()
}
@Test
@SmallTest
fun testFromDavResponseAddressBook() {
// r/w address book
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
assertTrue(info.privWriteContent)
assertTrue(info.privUnbind)
assertNull(info.supportsVEVENT)
assertNull(info.supportsVTODO)
assertNull(info.supportsVJOURNAL)
assertEquals("My Contacts", info.displayName)
assertEquals("My Contacts Description", info.description)
}
@Test
@SmallTest
fun testFromDavResponseCalendar() {
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
assertFalse(info.privWriteContent)
assertFalse(info.privUnbind)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timezone)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
@SmallTest
fun testFromDavResponseWebcal() {
// Webcal subscription
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
"<response>" +
" <href>/webcal1</href>" +
" <propstat><prop>" +
" <displayname>Sample Subscription</displayname>" +
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_WEBCAL, info.type)
assertEquals("Sample Subscription", info.displayName)
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
}
}

View File

@@ -1,154 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkCapabilities.TRANSPORT_WIFI
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class ConnectionUtilsTest {
@get:Rule
val mockkRule = MockKRule(this)
private val connectivityManager = mockk<ConnectivityManager>()
private val network1 = mockk<Network>()
private val network2 = mockk<Network>()
private val capabilities = mockk<NetworkCapabilities>()
@Before
fun setUp() {
every { connectivityManager.allNetworks } returns arrayOf(network1, network2)
every { connectivityManager.getNetworkInfo(network1) } returns mockk()
every { connectivityManager.getNetworkInfo(network2) } returns mockk()
every { connectivityManager.getNetworkCapabilities(network1) } returns capabilities
every { connectivityManager.getNetworkCapabilities(network2) } returns capabilities
}
@Test
fun testWifiAvailable_capabilitiesNull() {
every { connectivityManager.getNetworkCapabilities(network1) } returns null
every { connectivityManager.getNetworkCapabilities(network2) } returns null
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable_wifi() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable_validated() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable_wifiValidated() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertTrue(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testInternetAvailable_capabilitiesNull() {
every { connectivityManager.getNetworkCapabilities(network1) } returns null
every { connectivityManager.getNetworkCapabilities(network2) } returns null
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_Internet() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_Validated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_InternetValidated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_ignoreVpns() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, true))
}
@Test
fun testInternetAvailable_ignoreVpns_Notvpn() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() {
// The real case that failed in davx5-ose#395 is that the connection list contains (in this order)
// 1. a mobile network without INTERNET, but with VALIDATED
// 2. a WiFi network with INTERNET and VALIDATED
// The "return false" of hasINTERNET will trigger at the first connection, the
// "andThen true" will trigger for the second connection
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
// There is an internet connection if any(!) connection has both INTERNET and VALIDATED.
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotvpn() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
}
}

View File

@@ -1,79 +0,0 @@
package at.bitfire.davdroid.repository
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class DavCollectionRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var db: AppDatabase
val context = InstrumentationRegistry.getInstrumentation().targetContext
var service: Service? = null
@Before
fun setUp() {
hiltRule.inject()
service = createTestService(Service.TYPE_CARDDAV)!!
}
@After
fun cleanUp() {
db.close()
serviceRepository.deleteAll()
}
@Test
fun testOnChangeListener_setForceReadOnly() = runBlocking {
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
serviceId = service!!.id,
type = Collection.TYPE_ADDRESSBOOK,
url = "https://example.com".toHttpUrl(),
forceReadOnly = false,
)
)
val testObserver = mockk<DavCollectionRepository.OnChangeListener>(relaxed = true)
val collectionRepository = DavCollectionRepository(context, mutableSetOf(testObserver), serviceRepository, db)
assert(db.collectionDao().get(collectionId)?.forceReadOnly == false)
verify(exactly = 0) {
testObserver.onCollectionsChanged()
}
collectionRepository.setForceReadOnly(collectionId, true)
assert(db.collectionDao().get(collectionId)?.forceReadOnly == true)
verify(exactly = 1) {
testObserver.onCollectionsChanged()
}
}
// Test helpers and dependencies
private fun createTestService(serviceType: String) : Service? {
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
val serviceId = serviceRepository.insertOrReplace(service)
return serviceRepository.get(serviceId)
}
}

View File

@@ -1,78 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class DavHomeSetRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var repository: DavHomeSetRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val serviceId = createTestService()
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.apply { id = 1L }, repository.getById(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.apply { id = 1L }, repository.getById(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = repository.insertOrUpdateByUrl(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
}
@Test
fun testDelete() {
// should delete row with given primary key (id)
val serviceId = createTestService()
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, repository.getById(1L))
repository.delete(entry1)
assertEquals(null, repository.getById(1L))
}
private fun createTestService() : Long {
val service = Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
return serviceRepository.insertOrReplace(service)
}
}

View File

@@ -1,78 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class LocalAddressBookTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val mainAccountType = context.getString(R.string.account_type)
val mainAccount = Account("main", mainAccountType)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
val addressBookAccount = Account("sub", addressBookAccountType)
val accountManager = AccountManager.get(context)
@Before
fun setUp() {
hiltRule.inject()
// TODO DOES NOT WORK: the account immediately starts to sync, which creates the sync adapter services.
// The services however can't be created because Hilt is "not ready" (although it has been initialized in the line above).
// assertTrue(AccountUtils.createAccount(context, mainAccount, AccountSettings.initialUserData(null)))
}
@After
fun cleanup() {
accountManager.removeAccount(addressBookAccount, null, null)
accountManager.removeAccount(mainAccount, null, null)
}
// TODO see above
/*@Test
fun testMainAccount_AddressBookAccount_WithMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle().apply {
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
}))
// check mainAccount()
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
}
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle()))
// check mainAccount(); should fail because there's no main account
LocalAddressBook.mainAccount(context, addressBookAccount)
}*/
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_OtherAccount() {
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
}
}

View File

@@ -1,145 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.*
import org.junit.Assert.assertEquals
import org.junit.rules.TestRule
class LocalCalendarTest {
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpProvider() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun closeProvider() {
provider.closeCompat()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var calendar: LocalCalendar
@Before
fun prepare() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun shutdown() {
calendar.delete()
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
status = Status.VEVENT_CANCELLED
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(1, cursor.getInt(0))
}
}
@Test
// Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule)
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
}

View File

@@ -1,472 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.techbee.jtx.JtxContract.asSyncAdapter
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.*
import org.junit.*
import org.junit.Assert.*
import org.junit.rules.TestRule
import java.util.*
class LocalEventTest {
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var provider: ContentProviderClient
private lateinit var calendar: LocalCalendar
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.closeCompat()
}
}
@Before
fun createCalendar() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun removeCalendar() {
calendar.delete()
}
@Test
fun testNumDirectInstances_SingleInstance() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_Recurring() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_Recurring_Endless() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event without end"
rRules.add(RRule("FREQ=DAILY"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_Recurring_LateEnd() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 53 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
else
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_Recurring_ManyInstances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 2 years"
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!)
// Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct),
// but we are satisfied with either result for now
assertTrue(number == 365*2 || number == 365*2+1)
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_RecurringWithExdate() {
val event = Event().apply {
dtStart = DtStart(Date("20220120T010203Z"))
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME)))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_RecurringWithExceptions() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T130203Z")
summary = "Exception on 3rd day"
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220124T010203Z")
dtStart = DtStart("20220122T160203Z")
summary = "Exception on 5th day"
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
localEvent.add()
assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_SingleInstance() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_Recurring() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_Recurring_Endless() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with infinite instances"
rRules.add(RRule("FREQ=YEARLY"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_Recurring_LateEnd() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!))
else
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_Recurring_ManyInstances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over two years"
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
365*2 // Android <10: does not include UNTIL (incorrect!)
else
365*2 + 1, // Android ≥10: includes UNTIL (correct)
LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_RecurringWithExceptions() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 6 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=6"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T130203Z")
summary = "Exception on 3rd day"
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220124T010203Z")
dtStart = DtStart("20220122T160203Z")
summary = "Exception on 5th day"
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
val uri = localEvent.add()
calendar.findById(localEvent.id!!)
assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testMarkEventAsDeleted() {
// Create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "A fine event"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
// Delete event
LocalEvent.markAsDeleted(provider, account, localEvent.id!!)
// Get the status of whether the event is deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.DELETED),
null,
null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(1, cursor.getInt(0))
}
}
@Test
fun testPrepareForUpload_NoUid() {
// create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event without uid"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add() // save it to calendar storage
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
// throws an exception if fileName is not an UUID
UUID.fromString(fileName)
// UID in calendar storage should be the same as file name
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(fileName, cursor.getString(0))
}
}
@Test
fun testPrepareForUpload_NormalUid() {
// create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with normal uid"
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add() // save it to calendar storage
// prepare for upload - this should use the UID for the file name
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
assertEquals(event.uid, fileName)
// UID in calendar storage should still be set, too
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(fileName, cursor.getString(0))
}
}
@Test
fun testPrepareForUpload_UidHasDangerousChars() {
// create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with funny uid"
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add() // save it to calendar storage
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
// throws an exception if fileName is not an UUID
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(event.uid, cursor.getString(0))
}
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
// TODO
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
status = Status.VEVENT_CANCELLED
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(1, cursor.getInt(0))
}
}
@Test
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
}

View File

@@ -1,267 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.Manifest
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
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
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.*
import org.junit.Assert.*
import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
}
}
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"
}, null, null, 0
).apply {
add()
}
@Before
fun clearContacts() {
addressBookGroupsAsCategories.clear()
addressBookGroupsAsVCards.clear()
}
@Test
fun testApplyPendingMemberships_addPendingMembership() {
val ab = addressBookGroupsAsVCards
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val group = newGroup(ab)
// set pending membership of contact1
ab.provider!!.update(
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
ContentValues().apply {
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
},
null, null
)
// pending membership -> contact1 should be added to group
LocalGroup.applyPendingMemberships(ab)
// check group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
}
@Test
fun testApplyPendingMemberships_removeMembership() {
val ab = addressBookGroupsAsVCards
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val group = newGroup(ab)
// add contact1 to group
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
// no pending memberships -> membership should be removed
LocalGroup.applyPendingMemberships(ab)
// check group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
}
@Test
fun testClearDirty_addCachedGroupMembership() {
val ab = addressBookGroupsAsCategories
val group = newGroup(ab)
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
// insert group membership, but no cached group membership
ab.provider!!.insert(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
put(GroupMembership.GROUP_ROW_ID, group.id)
}
)
group.clearDirty(null, null)
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
}
@Test
fun testClearDirty_removeCachedGroupMembership() {
val ab = addressBookGroupsAsCategories
val group = newGroup(ab)
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
// insert cached group membership, but no group membership
ab.provider!!.insert(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
put(CachedGroupMembership.GROUP_ID, group.id)
}
)
group.clearDirty(null, null)
// cached group membership should be gone
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
}
@Test
fun testMarkMembersDirty() {
val ab = addressBookGroupsAsCategories
val group = newGroup(ab)
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
assertEquals(0, ab.findDirty().size)
group.markMembersDirty()
assertEquals(contact1.id, ab.findDirty().first().id)
}
@Test
fun testPrepareForUpload() {
val group = newGroup()
assertNull(group.getContact().uid)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
}
}

View File

@@ -1,38 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.vcard4android.GroupMethod
class LocalTestAddressBook(
context: Context,
provider: ContentProviderClient,
override val groupMethod: GroupMethod
): LocalAddressBook(context, ACCOUNT, provider) {
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
}
override var mainAccount: Account?
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
override var readOnly: Boolean
get() = false
set(_) = throw NotImplementedError()
fun clear() {
for (contact in queryContacts(null, null))
contact.delete()
for (group in queryGroups(null, null))
group.delete()
}
}

View File

@@ -1,63 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.content.ContentProviderClient
import android.content.ContentValues
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import org.junit.*
import org.junit.Assert.assertArrayEquals
class CachedGroupMembershipHandlerTest {
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBook: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
}
}
@Test
fun testMembership() {
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 123456)
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
}, contact)
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
}
}

View File

@@ -1,75 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.content.ContentProviderClient
import android.net.Uri
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.resource.LocalTestAddressBook
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import org.junit.*
import org.junit.Assert.assertEquals
class GroupMembershipBuilderTest {
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
}
}
@Test
fun testCategories_GroupsAsCategories() {
val contact = Contact().apply {
categories += "TEST GROUP"
}
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
assertEquals(1, result.size)
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
}
}
@Test
fun testCategories_GroupsAsVCards() {
val contact = Contact().apply {
categories += "TEST GROUP"
}
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
}
}
}

View File

@@ -1,79 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.content.ContentProviderClient
import android.content.ContentValues
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import org.junit.*
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
class GroupMembershipHandlerTest {
@JvmField
@Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private var addressBookGroupsAsCategoriesGroup: Long = -1
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@Before
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@After
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
}
@Test
fun testMembership_GroupsAsCategories() {
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
}, contact)
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
}
@Test
fun testMembership_GroupsAsVCards() {
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
}, contact)
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
assertTrue(contact.categories.isEmpty())
}
}

View File

@@ -1,717 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockkObject
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.apache.commons.lang3.StringUtils
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class CollectionListRefresherTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var settings: SettingsManager
@Before
fun setUp() {
hiltRule.inject()
}
// Test dependencies
companion object {
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
private val mockServer = MockWebServer()
private lateinit var client: HttpClient
@Before
fun mockServerSetup() {
// Start mock web server
mockServer.dispatcher = TestDispatcher()
mockServer.start()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun cleanUp() {
mockServer.shutdown()
db.close()
}
@Test
fun testDiscoverHomesets() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
// Check home sets have been saved to database
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
assertEquals(1, db.homeSetDao().getByService(service.id).size)
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().getByService(service.id).first()
)
}
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshHomelessCollections
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomelessCollections_deletesInaccessibleCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshPrincipals
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
assertEquals(null, principals[0].displayName)
}
@Test
fun refreshPrincipals_updatesPrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
assertEquals(0, principals.size)
}
// Others
@Test
fun shouldPreselect_none() {
val service = createTestService(Service.TYPE_CARDDAV)!!
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_all() {
val service = createTestService(Service.TYPE_CARDDAV)!!
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_all_blacklisted() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_personal_notPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_personal_isPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
// Test helpers and dependencies
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)
}
class TestDispatcher: Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = StringUtils.removeEnd(request.path!!, "/")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
"<current-user-principal>" +
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
"</current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
"<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
" <href>mailto:email1@example.com</href>" +
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
var responseBody = ""
var responseCode = 207
when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
responseCode = 404
else ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
}
Logger.log.info("Queried: $path")
Logger.log.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<resources>
<string name="app_name">Davx5Test</string>
</resources>

View File

@@ -1,3 +0,0 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="at.bitfire.davdroid.SyncManagerTest"
android:label="Test Account" />

View File

@@ -1,73 +0,0 @@
/*
* 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 android.content.Context
import android.content.SyncResult
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@HiltAndroidTest
class SyncerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext
/** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */
val mockAuthority = context.getString(R.string.webdav_authority)
val mockProvider = context.contentResolver!!.acquireContentProviderClient(mockAuthority)!!
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testOnPerformSync_runsSyncAndSetsClassLoader() {
val syncer = TestSyncer(context)
syncer.onPerformSync(account, arrayOf(), mockAuthority, mockProvider, SyncResult())
// check whether onPerformSync() actually calls sync()
assertEquals(1, syncer.syncCalled.get())
// check whether contextClassLoader is set
assertEquals(context.classLoader, Thread.currentThread().contextClassLoader)
}
class TestSyncer(context: Context) : Syncer(context) {
val syncCalled = AtomicInteger()
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
Thread.sleep(1000)
syncCalled.incrementAndGet()
}
}
}

View File

@@ -1,151 +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.accounts.AccountManager
import android.content.ContentResolver
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.ConnectionUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.AccountUtils
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.util.PermissionUtils
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class BaseSyncWorkerTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext
private val accountManager = AccountManager.get(context)
private val account = Account("Test Account", context.getString(R.string.account_type))
private val fakeCredentials = Credentials("test", "test")
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun inject() = hiltRule.inject()
@Before
fun setUp() {
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun removeAccount() {
accountManager.removeAccountExplicitly(account)
}
@Test
fun testWifiConditionsMet_withoutWifi() {
val accountSettings = mockk<AccountSettings>()
every { accountSettings.getSyncWifiOnly() } returns false
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
val accountSettings = AccountSettings(context, account)
accountSettings.setSyncWiFiOnly(true)
mockkObject(ConnectionUtils)
every { ConnectionUtils.wifiAvailable(any()) } returns true
mockkObject(BaseSyncWorker.Companion)
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
val accountSettings = AccountSettings(context, account)
accountSettings.setSyncWiFiOnly(true)
mockkObject(ConnectionUtils)
every { ConnectionUtils.wifiAvailable(any()) } returns false
mockkObject(BaseSyncWorker.Companion)
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
assertFalse(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testCorrectWifiSsid_CorrectWiFiSsid() {
val accountSettings = AccountSettings(context, account)
mockkObject(accountSettings)
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi")
mockkObject(PermissionUtils)
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
val wifiManager = context.getSystemService<WifiManager>()!!
mockkObject(wifiManager)
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
every { ssid } returns "ConnectedWiFi"
}
assertTrue(BaseSyncWorker.correctWifiSsid(context, accountSettings))
}
@Test
fun testCorrectWifiSsid_WrongWiFiSsid() {
val accountSettings = AccountSettings(context, account)
mockkObject(accountSettings)
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2")
mockkObject(PermissionUtils)
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
val wifiManager = context.getSystemService<WifiManager>()!!
mockkObject(wifiManager)
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
every { ssid } returns "ConnectedWiFi"
}
assertFalse(BaseSyncWorker.correctWifiSsid(context, accountSettings))
}
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.content.Context
import android.provider.CalendarContract
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.sync.SyncManagerTest.Companion.account
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert
import org.junit.Test
@HiltAndroidTest
class OneTimeSyncWorkerTest {
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun testEnqueue_enqueuesWorker() {
OneTimeSyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
Assert.assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
}

View File

@@ -1,149 +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.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.AccountUtils
import at.bitfire.davdroid.ui.NotificationUtils
import dagger.assisted.AssistedFactory
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PeriodicSyncWorkerTest {
companion object {
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val accountManager = AccountManager.get(context)
private val account = Account("Test Account", context.getString(R.string.account_type))
private val fakeCredentials = Credentials("test", "test")
@BeforeClass
@JvmStatic
fun setUp() {
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
}
@AfterClass
@JvmStatic
fun removeAccount() {
accountManager.removeAccountExplicitly(account)
}
}
@AssistedFactory
interface PeriodicSyncWorkerFactory {
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject
lateinit var syncWorkerFactory: PeriodicSyncWorkerFactory
@Before
fun inject() {
hiltRule.inject()
}
@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", context.getString(R.string.account_type))
// Run PeriodicSyncWorker as TestWorker
val inputData = workDataOf(
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)
// mock WorkManager to observe cancellation call
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
// run test worker, expect failure
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
syncWorkerFactory.create(appContext, workerParameters)
})
.build()
val result = runBlocking {
testWorker.doWork()
}
assertTrue(result is ListenableWorker.Result.Failure)
// verify that worker called WorkManager.cancelWorkById(<its ID>)
verify {
workManager.cancelWorkById(testWorker.id)
}
}
}

View File

@@ -1,352 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<!-- normal permissions -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- account management permissions not required for own accounts since API level 22 -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<!-- android.permission-group.CALENDAR -->
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.LOCATION -->
<!-- getting the WiFi name (for "sync in Wifi only") requires
- coarse location (Android 8.1)
- fine location (Android 10) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- ical4android declares task access permissions -->
<!-- Disable GPS capability requirement, which is implicitly derived from ACCESS_FINE_LOCATION
permission and makes app unusable on some devices without GPS. We need location permissions only
to get the current WiFi SSID, and we don't need GPS for that. -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<application
android:name=".App"
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:resizeableActivity="true"
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
<!-- required for Hilt/WorkManager integration -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
<application android:name=".App">
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
tools:node="remove" tools:selector="net.openid.appauth"/>
<activity android:name=".ui.intro.IntroActivity" />
<activity
android:name=".ui.AccountsActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.DebugInfoActivity"
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="true"
android:label="@string/debug_info_title">
<intent-filter>
<action android:name="android.intent.action.BUG_REPORT"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.PermissionsActivity"
android:label="@string/app_settings_security_app_permissions"
android:parentActivityName=".ui.AppSettingsActivity" />
<activity
android:name=".ui.TasksActivity"
android:label="@string/intro_tasks_title"
android:parentActivityName=".ui.AppSettingsActivity" />
<activity
android:name=".ui.setup.LoginActivity"
android:parentActivityName=".ui.AccountsActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="caldav"/>
<data android:scheme="caldavs"/>
<data android:scheme="carddav"/>
<data android:scheme="carddavs"/>
<data android:scheme="davx5"/>
</intent-filter>
<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 android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
</activity>
<activity
android:name=".ui.account.CollectionActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateAddressBookActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateCalendarActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.AccountSettingsActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.WifiPermissionsActivity"
android:parentActivityName=".ui.account.AccountSettingsActivity" />
<activity
android:name=".ui.webdav.WebdavMountsActivity"
android:exported="true"
android:parentActivityName=".ui.AccountsActivity" />
<activity
android:name=".ui.webdav.AddWebdavMountActivity"
android:parentActivityName=".ui.webdav.WebdavMountsActivity"
android:windowSoftInputMode="adjustResize" />
<!-- account type "DAVx⁵" -->
<service
android:name=".sync.account.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".sync.adapter.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".sync.adapter.JtxSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_notes"/>
</service>
<service
android:name=".sync.adapter.OpenTasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".sync.adapter.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks_org"/>
</service>
<provider
android:authorities="@string/webdav_authority"
android:name=".webdav.DavDocumentsProvider"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<!-- account type "DAVx⁵ Address book" -->
<service
android:name=".sync.account.AddressBookAuthenticatorService"
android:exported="true"
tools:ignore="ExportedService"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator_address_book"/>
</service>
<provider
android:authorities="@string/address_books_authority"
android:exported="false"
android:label="@string/address_books_authority_title"
android:name=".sync.adapter.AddressBookProvider" />
<service
android:name=".sync.adapter.AddressBooksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_address_books"/>
</service>
<service
android:name=".sync.adapter.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts"/>
</service>
<!-- provider to share debug info/logs -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority_debug_provider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/debug_paths" />
</provider>
<!-- UnifiedPush receiver -->
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</receiver>
<!-- Widgets -->
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info_sync_button" />
</receiver>
tools:node="remove" tools:selector="net.openid.appauth"/>
</application>
<!-- package visiblity which apps do we need to see? -->
<queries>
<!-- system providers (listing them is technically not required, but some apps like the
Huawei calendar take this as indication of whether these providers are accessed) -->
<provider android:authorities="com.android.calendar"/>
<provider android:authorities="com.android.contacts"/>
<!-- task providers -->
<package android:name="at.techbee.jtx" />
<package android:name="org.dmfs.tasks" />
<package android:name="org.tasks" />
<!-- ICSx5 for Webcal feeds -->
<package android:name="at.bitfire.icsdroid"/>
<!-- apps that interact with contact, calendar, task data (for debug info) -->
<intent>
<action android:name="*" />
<data android:scheme="content" android:host="com.android.contacts" />
</intent>
<intent>
<action android:name="*" />
<data android:scheme="content" android:host="com.android.calendar" />
</intent>
<!-- Open URLs in a browser or other app [https://developer.android.com/training/package-visibility/use-cases#open-urls-browser-or-other-app] -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<!-- Custom Tabs support (e.g. Nextcloud Login Flow) -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>

View File

@@ -1 +0,0 @@
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces","pikamoku"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}

View File

@@ -1,85 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.app.Application
import android.os.StrictMode
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.UiUtils
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.logging.Level
import javax.inject.Inject
import kotlin.system.exitProcess
@HiltAndroidApp
class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
Logger.initialize(this)
if (BuildConfig.DEBUG)
// debug builds
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build())
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
// handle uncaught exceptions in non-debug standard flavor
Thread.setDefaultUncaughtExceptionHandler(this)
NotificationUtils.createChannels(this)
// set light/dark mode
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
// some current activity and causes an IllegalStateException in rare cases
// don't block UI for some background checks
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Default) {
// clean up orphaned accounts in DB from time to time
AccountsCleanupWorker.enqueue(this@App)
// watch installed/removed tasks apps over whole app lifetime and update sync settings accordingly
TasksAppWatcher.watchInstalledTaskApps(this@App, this)
// create/update app shortcuts
UiUtils.updateShortcuts(this@App)
}
}
override fun uncaughtException(t: Thread, e: Throwable) {
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
val intent = DebugInfoActivity.IntentBuilder(this)
.withCause(e)
.newTask()
.build()
startActivity(intent)
exitProcess(1)
}
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.net.Uri
import androidx.core.net.toUri
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
*/
object Constants {
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
val HOMEPAGE_URL = "https://www.davx5.com".toUri()
const val HOMEPAGE_PATH_FAQ = "faq"
const val HOMEPAGE_PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected"
const val HOMEPAGE_PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission"
const val HOMEPAGE_PATH_OPEN_SOURCE = "donate"
const val HOMEPAGE_PATH_PRIVACY = "privacy"
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
val MANUAL_URL = "https://manual.davx5.com".toUri()
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
const val MANUAL_PATH_SETTINGS = "settings.html"
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
const val MANUAL_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
val FEDIVERSE_HANDLE = "@davx5app@fosstodon.org"
val FEDIVERSE_URL = "https://fosstodon.org/@davx5app".toUri()
/**
* Appends query parameters for anonymized usage statistics (app ID, version).
* Can be used by the called Website to get an idea of which versions etc. are currently used.
*
* @param context optional info about from where the URL was opened (like a specific Activity)
*/
fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder {
appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
if (context != null)
appendQueryParameter("pk_kwd", context)
return this
}
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.Context
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.util.packageChangedFlow
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Watches whether a tasks app has been installed or uninstalled and updates
* the selected tasks app and task sync settings accordingly.
*/
object TasksAppWatcher {
fun watchInstalledTaskApps(context: Context, externalScope: CoroutineScope) {
externalScope.launch(Dispatchers.Default) {
packageChangedFlow(context).collect {
onPackageChanged(context)
}
}
}
private fun onPackageChanged(context: Context) {
val currentProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or package (un)installed; current tasks provider = $currentProvider")
if (currentProvider == null) {
// Iterate through all supported providers and select one, if available.
var providerSelected = false
for (provider in TaskProvider.ProviderName.entries) {
val available = context.packageManager.resolveContentProvider(provider.authority, 0) != null
if (available) {
Logger.log.info("Selecting new tasks provider: $provider")
TaskUtils.selectProvider(context, provider)
providerSelected = true
break
}
}
if (!providerSelected)
// no provider available (anymore), also clear setting and sync
TaskUtils.selectProvider(context, null)
}
}
}

View File

@@ -1,305 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import android.accounts.AccountManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.Writer
import javax.inject.Singleton
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
Collection::class,
Principal::class,
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 13, autoMigrations = [
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
AutoMigration(from = 12, to = 13)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object AppDatabaseModule {
@Provides
@Singleton
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(*migrations)
.addAutoMigrationSpec(AutoMigration11_12(context))
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
val nm = NotificationManagerCompat.from(context)
val launcherIntent = Intent(context, AccountsActivity::class.java)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
.setContentText(context.getString(R.string.database_destructive_migration_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setAutoCancel(true)
.build()
nm.notifyIfPossible(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify)
// 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)
}
})
.build()
}
// auto migrations
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
Logger.log.info("Database update to v12, refreshing services to get display names of owners")
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
RefreshCollectionsWorker.enqueue(context, serviceId)
}
}
}
}
companion object {
// manual migrations
val migrations: Array<Migration> = arrayOf(
object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE syncstats (" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
"authority TEXT NOT NULL," +
"lastSync INTEGER NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
}
},
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
}
},
object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
}
},
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
val sql = arrayOf(
// migrate "services" to "service": rename columns, make id NOT NULL
"CREATE TABLE service(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"accountName TEXT NOT NULL," +
"type TEXT NOT NULL," +
"principal TEXT DEFAULT NULL" +
")",
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
"DROP TABLE services",
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
"CREATE TABLE homeset(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"url TEXT NOT NULL," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
"DROP TABLE homesets",
// migrate "collections" to "collection": rename columns, make id NOT NULL
"CREATE TABLE collection(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"type TEXT NOT NULL," +
"url TEXT NOT NULL," +
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
"privUnbind INTEGER NOT NULL DEFAULT 1," +
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
"displayName TEXT DEFAULT NULL," +
"description TEXT DEFAULT NULL," +
"color INTEGER DEFAULT NULL," +
"timezone TEXT DEFAULT NULL," +
"supportsVEVENT INTEGER DEFAULT NULL," +
"supportsVTODO INTEGER DEFAULT NULL," +
"supportsVJOURNAL INTEGER DEFAULT NULL," +
"source TEXT DEFAULT NULL," +
"sync INTEGER NOT NULL DEFAULT 0," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
"DROP TABLE collections"
)
sql.forEach { db.execSQL(it) }
}
},
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
}
},
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
},
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// We don't have access to the context in a Room migration now, so
// we will just drop those settings from old DAVx5 versions.
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
try {
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
when (cursor.getString(0)) {
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
}
}
}
db.execSQL("DROP TABLE settings")
} finally {
edit.apply()
}*/
}
},
object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
db.execSQL("UPDATE collections SET type=(" +
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
"FROM services WHERE _id=collections.serviceID" +
")",
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
}
}
)
}
// DAOs
abstract fun serviceDao(): ServiceDao
abstract fun homeSetDao(): HomeSetDao
abstract fun collectionDao(): CollectionDao
abstract fun principalDao(): PrincipalDao
abstract fun syncStatsDao(): SyncStatsDao
abstract fun webDavDocumentDao(): WebDavDocumentDao
abstract fun webDavMountDao(): WebDavMountDao
// helpers
fun dump(writer: Writer, ignoreTables: Array<String>) {
val db = openHelper.readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
while (cursorTables.moveToNext()) {
val tableName = cursorTables.getString(0)
if (ignoreTables.contains(tableName)) {
writer.append("$tableName: ")
db.query("SELECT COUNT(*) FROM $tableName").use { cursor ->
if (cursor.moveToNext())
writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n")
}
} else {
writer.append("$tableName\n")
db.query("SELECT * FROM $tableName").use { cursor ->
val table = TextTable(*cursor.columnNames)
val cols = cursor.columnCount
// print rows
while (cursor.moveToNext()) {
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
table.addLine(*values)
}
writer.append(table.toString())
}
}
}
db.endTransaction()
}
}
}

View File

@@ -1,46 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.util.Log
import org.apache.commons.lang3.math.NumberUtils
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
object LogcatHandler: Handler() {
private const val MAX_LINE_LENGTH = 3000
init {
formatter = PlainTextFormatter.LOGCAT
level = Level.ALL
}
override fun publish(r: LogRecord) {
val text = formatter.format(r)
val level = r.level.intValue()
val end = text.length
var pos = 0
while (pos < end) {
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
when {
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
else -> Log.v(r.loggerName, line)
}
pos += MAX_LINE_LENGTH
}
}
override fun flush() {}
override fun close() {}
}

View File

@@ -1,157 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.app.Application
import android.app.PendingIntent
import android.content.Intent
import android.content.SharedPreferences
import android.os.Process
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import java.io.File
import java.io.IOException
import java.util.Date
import java.util.logging.FileHandler
import java.util.logging.Level
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
private const val LOGGER_NAME = "davx5"
const val LOG_TO_FILE = "log_to_file"
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger(LOGGER_NAME)
private lateinit var context: Application
private lateinit var preferences: SharedPreferences
fun initialize(app: Application) {
context = app
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener(this)
reinitialize()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
if (key == LOG_TO_FILE) {
log.info("Logging settings changed; re-initializing logger")
reinitialize()
}
}
@Synchronized
private fun reinitialize() {
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(log.name, Log.DEBUG)
log.info("Verbose logging: $logVerbose; to file: $logToFile")
// set logging level according to preferences
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
// reset all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { handler ->
rootLogger.removeHandler(handler)
if (handler is FileHandler) // gracefully close previous verbose-logging FileHandlers
handler.close()
}
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val logFile = getDebugLogFile() ?: return log.warning("Log file could not be retrieved.")
if (logFile.createNewFile())
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
try {
val fileHandler = FileHandler(logFile.toString(), true).apply {
formatter = PlainTextFormatter.DEFAULT
}
rootLogger.addHandler(fileHandler)
log.info("Now logging to file: $logFile")
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.app_settings_logging))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(context.getString(R.string.logging_notification_text, context.getString(R.string.app_name)))
.setOngoing(true)
val shareIntent = DebugInfoActivity.IntentBuilder(context)
.newTask()
.share()
val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(
R.drawable.ic_share,
context.getString(R.string.logging_notification_view_share),
pendingShare
).build())
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingPref = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(
R.drawable.ic_settings,
context.getString(R.string.logging_notification_disable),
pendingPref
).build())
nm.notifyIfPossible(NotificationUtils.NOTIFY_VERBOSE_LOGGING, builder.build())
} catch(e: IOException) {
log.log(Level.SEVERE, "Couldn't create log file", e)
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
}
} else {
// verbose logging is disabled -> cancel notification and remove old logs
nm.cancel(NotificationUtils.NOTIFY_VERBOSE_LOGGING)
debugDir()?.deleteRecursively()
}
}
/**
* Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored.
* Must match the contents of `res/xml/debug.paths.xml`.
*
* @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully.
*/
fun debugDir(): File? {
val dir = File(context.filesDir, "debug")
if (dir.exists() && dir.isDirectory)
return dir
if (dir.mkdir())
return dir
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
return null
}
/**
* The file (in [debugDir]) where verbose logs are stored.
*
* @return The file where verbose logs are stored, or `null` if there's no [debugDir].
*/
fun getDebugLogFile(): File? {
val logDir = debugDir() ?: return null
return File(logDir, "davx5-log.txt")
}
}

View File

@@ -1,58 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import java.util.Locale
import java.util.logging.Formatter
import java.util.logging.LogRecord
class PlainTextFormatter private constructor(
private val logcat: Boolean
): Formatter() {
companion object {
val LOGCAT = PlainTextFormatter(true)
val DEFAULT = PlainTextFormatter(false)
const val MAX_MESSAGE_LENGTH = 20000
}
override fun format(r: LogRecord): String {
val builder = StringBuilder()
if (!logcat)
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss", Locale.ROOT))
.append(" ").append(r.threadID).append(" ")
val className = shortClassName(r.sourceClassName)
if (className != r.loggerName)
builder.append("[").append(className).append("] ")
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
r.thrown?.let {
builder .append("\nEXCEPTION ")
.append(ExceptionUtils.getStackTrace(it))
}
r.parameters?.let {
for ((idx, param) in it.withIndex())
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
}
if (!logcat)
builder.append("\n")
return builder.toString()
}
private fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
.replace(Regex("\\$.*$"), "")
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import java.util.logging.Handler
import java.util.logging.LogRecord
class StringHandler: Handler() {
val builder = StringBuilder()
init {
formatter = PlainTextFormatter.DEFAULT
}
override fun publish(record: LogRecord) {
builder.append(formatter.format(record))
}
override fun flush() {}
override fun close() {}
override fun toString() = builder.toString()
}

View File

@@ -1,69 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
import okhttp3.Interceptor
import okhttp3.Response
import java.util.logging.Level
/**
* Sends an OAuth Bearer token authorization as described in RFC 6750.
*/
class BearerAuthInterceptor(
private val accessToken: String
): Interceptor {
companion object {
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
return runBlocking {
val accessTokenFuture = CompletableDeferred<String>()
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
if (accessToken != null) {
// persist updated AuthState
callback?.onUpdate(authState)
// emit access token
accessTokenFuture.complete(accessToken)
}
else {
Logger.log.log(Level.WARNING, "Couldn't obtain access token", ex)
accessTokenFuture.cancel()
}
}
// return value
try {
BearerAuthInterceptor(accessTokenFuture.await())
} catch (ignored: CancellationException) {
null
}
}
}
}
override fun intercept(chain: Interceptor.Chain): Response {
Logger.log.finer("Authenticating request with access token")
val rq = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(rq)
}
fun interface AuthStateUpdateCallback {
fun onUpdate(authState: AuthState)
}
}

View File

@@ -1,73 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import at.bitfire.davdroid.log.Logger
import java.util.logging.Level
object ConnectionUtils {
/**
* Checks whether we are connected to validated WiFi
*/
internal fun wifiAvailable(connectivityManager: ConnectivityManager): Boolean {
connectivityManager.allNetworks.forEach { network ->
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
return true
}
}
return false
}
/**
* Checks whether we are connected to the Internet.
*
* On API 26+ devices, if a VPN is used, WorkManager might start the SyncWorker without an
* Internet connection (because [NetworkCapabilities.NET_CAPABILITY_VALIDATED] is always set for VPN connections).
* To prevent the start without internet access, we don't check for VPN connections by default
* (by using [NetworkCapabilities.NET_CAPABILITY_NOT_VPN]).
*
* However in special occasions (when syncing over a VPN without validated Internet on the
* underlying connection) we do not want to exclude VPNs.
*
* @param ignoreVpns *true* filters VPN connections in the Internet check; *false* allows them as valid connection
* @return whether we are connected to the Internet
*/
internal fun internetAvailable(connectivityManager: ConnectivityManager, ignoreVpns: Boolean): Boolean {
return connectivityManager.allNetworks.any { network ->
val capabilities = connectivityManager.getNetworkCapabilities(network)
Logger.log.log(Level.FINE, "Looking for validated Internet over this connection.",
arrayOf(connectivityManager.getNetworkInfo(network), capabilities))
if (capabilities != null) {
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
Logger.log.fine("Missing network capability: INTERNET")
return@any false
}
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
Logger.log.fine("Missing network capability: VALIDATED")
return@any false
}
if (ignoreVpns)
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
Logger.log.fine("Missing network capability: NOT_VPN")
return@any false
}
Logger.log.fine("This connection can be used.")
/* return@any */ true
} else
// no network capabilities available, we can't use this connection
/* return@any */ false
}
}
}

View File

@@ -1,88 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.network
import android.net.Uri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import java.net.URI
class GoogleLogin(
val authService: AuthorizationService
) {
companion object {
// davx5integration@gmail.com (for davx5-ose)
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
private val SCOPES = arrayOf(
"https://www.googleapis.com/auth/calendar", // CalDAV
"https://www.googleapis.com/auth/carddav" // CardDAV
)
/**
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
* _calid_ of the primary calendar is the account name.
*
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
* calendars.
*/
fun googleBaseUri(googleAccount: String): URI =
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token")
)
}
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
GoogleLogin.serviceConfig,
customClientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect")
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
suspend fun authenticate(authResponse: AuthorizationResponse): Credentials {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val credentials = CompletableDeferred<Credentials>()
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
credentials.complete(Credentials(authState = authState))
}
}
}
return credentials.await()
}
}

View File

@@ -1,343 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.os.Build
import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
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.components.SingletonComponent
import kotlinx.coroutines.flow.MutableStateFlow
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import okhttp3.Cache
import okhttp3.ConnectionSpec
import okhttp3.CookieJar
import okhttp3.Interceptor
import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.Protocol
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 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,
@Assisted private var authService: AuthorizationService? = null,
val settingsManager: SettingsManager
): AutoCloseable {
companion object {
/** max. size of disk cache (10 MB) */
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
/** Base Builder to build all clients from. Use rarely; [OkHttpClient]s should
* be reused as much as possible. */
fun baseBuilder() =
OkHttpClient.Builder()
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
// traffic within a minute, a sync will be cancelled.
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(
45,
TimeUnit.SECONDS
) // avoid cancellation because of missing traffic; only works for HTTP/2
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
.connectionSpecs(
listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.COMPATIBLE_TLS
)
)
// don't allow redirects by default, because it would break PROPFIND handling
.followRedirects(false)
// add User-Agent to every request
.addInterceptor(UserAgentInterceptor)
}
@AssistedFactory
interface Factory {
fun create(okHttpClient: OkHttpClient, authService: AuthorizationService?): HttpClient
}
override fun close() {
authService?.dispose()
okHttpClient.cache?.close()
}
class Builder(
val context: Context,
accountSettings: AccountSettings? = null,
val logger: java.util.logging.Logger? = Logger.log,
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface HttpClientBuilderEntryPoint {
fun authorizationService(): AuthorizationService
fun httpClientFactory(): HttpClient.Factory
fun settingsManager(): SettingsManager
}
private val entryPoint = EntryPointAccessors.fromApplication<HttpClientBuilderEntryPoint>(context)
fun interface CertManagerProducer {
fun certManager(): CustomCertManager
}
private var appInForeground: MutableStateFlow<Boolean>? =
MutableStateFlow(false)
private var authService: AuthorizationService? = null
private var certManagerProducer: CertManagerProducer? = null
private var certificateAlias: String? = null
private var offerCompression: Boolean = false
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
private var cookieStore: CookieJar? = MemoryCookieStore()
private val orig = baseBuilder()
init {
// add network logging, if requested
if (logger != null && logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
loggingInterceptor.level = loggerLevel
orig.addNetworkInterceptor(loggingInterceptor)
}
val settings = entryPoint.settingsManager()
// custom proxy support
try {
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
// we set our own proxy
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
InetSocketAddress(
settings.getString(Settings.PROXY_HOST),
settings.getInt(Settings.PROXY_PORT)
)
}
val proxy =
when (proxyTypeValue) {
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
else -> throw IllegalArgumentException("Invalid proxy type")
}
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy setting", proxy)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
customCertManager {
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
CustomCertManager(context, trustSystemCerts, appInForeground)
}
// use account settings for authentication and cookies
if (accountSettings != null)
addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState ->
accountSettings.credentials(Credentials(authState = authState))
})
}
constructor(context: Context, host: String?, credentials: Credentials?) : this(context) {
if (credentials != null)
addAuthentication(host, credentials)
}
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
if (credentials.username != null && credentials.password != null) {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive)
orig.addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
credentials.authState?.let { authState ->
val newAuthService = entryPoint.authorizationService()
authService = newAuthService
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
orig.addNetworkInterceptor(bearerAuthInterceptor)
}
}
return this
}
fun allowCompression(allow: Boolean): Builder {
offerCompression = allow
return this
}
fun cookieStore(store: CookieJar?): Builder {
cookieStore = store
return this
}
fun followRedirects(follow: Boolean): Builder {
orig.followRedirects(follow)
return this
}
fun customCertManager(producer: CertManagerProducer) {
certManagerProducer = producer
}
fun setForeground(foreground: Boolean): Builder {
appInForeground?.value = foreground
return this
}
fun withDiskCache(): Builder {
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
Logger.log.fine("Using disk cache: $cacheDir")
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
break
}
}
return this
}
fun build(): HttpClient {
cookieStore?.let {
orig.cookieJar(it)
}
if (offerCompression)
// offer Brotli and gzip compression
orig.addInterceptor(BrotliInterceptor)
var keyManager: KeyManager? = null
certificateAlias?.let { alias ->
// get provider certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
logger?.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
// create KeyManager
keyManager = object : X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
orig.protocols(listOf(Protocol.HTTP_1_1))
}
if (certManagerProducer != null || keyManager != null) {
val manager = certManagerProducer?.certManager()
val trustManager = manager ?: /* fall back to system default trust manager */
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.let { factory ->
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}
val hostnameVerifier =
if (manager != null)
manager.HostnameVerifier(OkHostnameVerifier)
else
OkHostnameVerifier
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
orig.hostnameVerifier(hostnameVerifier)
}
return entryPoint.httpClientFactory().create(orig.build(), authService = authService)
}
}
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 = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {
Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests")
}
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}
}

View File

@@ -1,59 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import org.apache.commons.collections4.keyvalue.MultiKey
import org.apache.commons.collections4.map.HashedMap
import org.apache.commons.collections4.map.MultiKeyMap
import java.util.LinkedList
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
class MemoryCookieStore: CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(storage) {
for (cookie in cookies)
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookies = LinkedList<Cookie>()
synchronized(storage) {
val iter = storage.mapIterator()
while (iter.hasNext()) {
iter.next()
val cookie = iter.value
// remove expired cookies
if (cookie.expiresAt <= System.currentTimeMillis()) {
iter.remove()
continue
}
// add applicable cookies
if (cookie.matches(url))
cookies += cookie
}
}
return cookies
}
}

View File

@@ -1,141 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.network
import android.content.Context
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.lang3.StringUtils
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
/**
* Implements Nextcloud Login Flow v2.
*
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class NextcloudLoginFlow(
context: Context
): AutoCloseable {
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"
/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
const val DAV_PATH = "remote.php/dav"
}
val httpClient = HttpClient.Builder(context)
.setForeground(true)
.build()
override fun close() {
httpClient.close()
}
// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var token: String? = null
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = null
pollUrl = null
token = null
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
return loginUrl
}
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath
if (path.endsWith(FLOW_V2_PATH))
// already a Login Flow v2 URL
return baseUrl
if (path.endsWith(FLOW_V1_PATH))
// Login Flow v1 URL, rewrite to v2
return baseUrl.newBuilder()
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
.build()
// other URL, make it a Login Flow v2 URL
return baseUrl.newBuilder()
.addPathSegments(FLOW_V2_PATH)
.build()
}
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = StringUtils.appendIfMissing(json.getString("server"), "/")
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword")
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.okHttpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body?.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
throw DavException("Invalid Login Flow response (no body)")
}
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.push.PushMessage
import at.bitfire.davdroid.log.Logger
import org.xmlpull.v1.XmlPullParserException
import java.io.StringReader
import java.util.logging.Level
import javax.inject.Inject
class PushMessageParser @Inject constructor() {
/**
* Parses a WebDAV-Push message and returns the `topic` that the message is about.
*
* @return topic of the modified collection, or `null` if the topic couldn't be determined
*/
operator fun invoke(message: String): String? {
var topic: String? = null
val parser = XmlUtils.newPullParser()
try {
parser.setInput(StringReader(message))
XmlUtils.processTag(parser, PushMessage.NAME) {
val pushMessage = PushMessage.Factory.create(parser)
topic = pushMessage.topic
}
} catch (e: XmlPullParserException) {
Logger.log.log(Level.WARNING, "Couldn't parse push message", e)
}
return topic
}
}

View File

@@ -1,164 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.accounts.Account
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.DavCollection
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.property.push.NS_WEBDAV_PUSH
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.settings.AccountSettings
import dagger.Binds
import dagger.Module
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
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.RequestBody.Companion.toRequestBody
import java.io.StringWriter
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
* Worker that registers push for all collections that support it.
* 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.
*/
@Suppress("unused")
@HiltWorker
class PushRegistrationWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParameters: WorkerParameters,
private val collectionRepository: DavCollectionRepository,
private val preferenceRepository: PreferenceRepository,
private val serviceRepository: DavServiceRepository
) : CoroutineWorker(context, workerParameters) {
companion object {
private const val UNIQUE_WORK_NAME = "push-registration"
/**
* Enqueues a push registration worker with a minimum delay of 5 seconds.
*/
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
.build()
val workRequest = OneTimeWorkRequestBuilder<PushRegistrationWorker>()
.setInitialDelay(5, TimeUnit.SECONDS)
.setConstraints(constraints)
.build()
Logger.log.info("Enqueueing push registration worker")
WorkManager.getInstance(context)
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest)
}
}
private suspend fun requestPushRegistration(collection: Collection, account: Account, endpoint: String) {
val settings = AccountSettings(applicationContext, account)
runInterruptible {
HttpClient.Builder(applicationContext, settings)
.setForeground(true)
.build()
.use { client ->
val httpClient = client.okHttpClient
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-register")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "subscription")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) {
text(endpoint)
}
}
}
}
serializer.endDocument()
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) runBlocking {
response.header("Location")?.let { subscriptionUrl ->
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
}
} else
Logger.log.warning("Couldn't register push for ${collection.url}: $response")
}
}
}
}
override suspend fun doWork(): Result {
Logger.log.info("Running push registration worker")
val endpoint = preferenceRepository.unifiedPushEndpoint()
if (endpoint != null)
for (collection in collectionRepository.getSyncableAndPushCapable()) {
Logger.log.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)
}
else
Logger.log.info("No UnifiedPush endpoint configured")
return Result.success()
}
/**
* Listener that enqueues a push registration worker when the collection list changes.
*/
class CollectionsListener @Inject constructor(
@ApplicationContext val context: Context
): DavCollectionRepository.OnChangeListener {
override fun onCollectionsChanged() = enqueue(context)
}
/**
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
*/
@Module
@InstallIn(SingletonComponent::class)
interface PushRegistrationWorkerModule {
@Binds
@IntoSet
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
}
}

View File

@@ -1,79 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import at.bitfire.davdroid.log.Logger
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 dagger.hilt.android.AndroidEntryPoint
import org.unifiedpush.android.connector.MessagingReceiver
import java.util.logging.Level
import javax.inject.Inject
@AndroidEntryPoint
class UnifiedPushReceiver: MessagingReceiver() {
@Inject
lateinit var accountRepository: AccountRepository
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var preferenceRepository: PreferenceRepository
@Inject
lateinit var parsePushMessage: PushMessageParser
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
// remember new endpoint
preferenceRepository.unifiedPushEndpoint(endpoint)
// register new endpoint at CalDAV/CardDAV servers
PushRegistrationWorker.enqueue(context)
}
override fun onUnregistered(context: Context, instance: String) {
// reset known endpoint
preferenceRepository.unifiedPushEndpoint(null)
}
override fun onMessage(context: Context, message: ByteArray, instance: String) {
val messageXml = message.toString(Charsets.UTF_8)
Logger.log.log(Level.INFO, "Received push message", messageXml)
// parse push notification
val topic = parsePushMessage(messageXml)
// sync affected collection
if (topic != null) {
Logger.log.info("Got push notification for topic $topic")
// Sync all authorities of account that the collection belongs to
// Later: only sync affected collection and authorities
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
val account = accountRepository.fromName(service.accountName)
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
}
}
} else {
Logger.log.warning("Got push message without topic, syncing all accounts")
for (account in accountRepository.getAll())
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
}
}
}

View File

@@ -1,310 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Application
import android.content.ContentResolver
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
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.AccountUtils
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import java.util.logging.Level
import javax.inject.Inject
/**
* Repository for managing CalDAV/CardDAV accounts.
*
* *Note:* This class is not related to address book accounts, which are managed by
* [at.bitfire.davdroid.resource.LocalAddressBook].
*/
class AccountRepository @Inject constructor(
val context: Application,
private val homeSetRepository: DavHomeSetRepository,
private val settingsManager: SettingsManager,
private val serviceRepository: DavServiceRepository,
private val collectionRepository: DavCollectionRepository
) {
private val accountType = context.getString(R.string.account_type)
private val accountManager = AccountManager.get(context)
/**
* Creates a new main account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param accountName name of the account
* @param credentials server credentials
* @param config discovered server capabilities for syncable authorities
* @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = fromName(accountName)
// create Android account
val userData = AccountSettings.initialUserData(credentials)
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = AccountSettings(context, account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (config.cardDAV != null) {
// insert CardDAV service
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// initial CardDAV account settings
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
return null
}
return account
}
suspend fun delete(accountName: String): Boolean {
// remove account
val future = accountManager.removeAccount(fromName(accountName), null, null, null)
return try {
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
future.result
}
// delete address book accounts
LocalAddressBook.deleteByAccount(context, accountName)
// delete from database
serviceRepository.deleteByAccount(accountName)
true
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't remove account $accountName", e)
false
}
}
fun exists(accountName: String): Boolean =
if (accountName.isEmpty())
false
else
accountManager
.getAccountsByType(accountType)
.any { it.name == accountName }
fun fromName(accountName: String) =
Account(accountName, accountType)
fun getAll(): Array<Account> = accountManager.getAccountsByType(accountType)
fun getAllFlow() = callbackFlow<Set<Account>> {
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
accountManager.addOnAccountsUpdatedListener(listener, null, true)
awaitClose {
accountManager.removeOnAccountsUpdatedListener(listener)
}
}
/**
* Renames an account.
*
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
* a consistent state.
*
* @param oldName current name of the account
* @param newName new name the account shall be re named to
*
* @throws InvalidAccountException if the account does not exist
* @throws IllegalArgumentException if the new account name already exists
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String) {
val oldAccount = fromName(oldName)
val newAccount = fromName(newName)
// check whether new account name already exists
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
throw IllegalArgumentException("Account with name \"$newName\" already exists")
// remember sync intervals
val oldSettings = AccountSettings(context, oldAccount)
val authorities = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
)
val tasksProvider = TaskUtils.currentProvider(context)
tasksProvider?.authority?.let { authorities.add(it) }
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
// rename account
try {
/* https://github.com/bitfireAT/davx5/issues/135
Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account
because this can cause problems when:
1. The account is renamed.
2. The AccountsCleanupWorker is called BEFORE the services table is updated.
→ AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore
3. Now the services would be renamed, but they're not here anymore. */
AccountsCleanupWorker.lockAccountsCleanup()
// rename account
val future = accountManager.renameAccount(oldAccount, newName, null, null)
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
}
// account renamed, cancel maybe running synchronization of old account
BaseSyncWorker.cancelAllWork(context, oldAccount)
// disable periodic syncs for old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, 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.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)
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(context, oldAccount.name, newName)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e)
// Couldn't update task lists, but this is not a fatal error (will be fixed at next sync)
}
// restore sync intervals
val newSettings = AccountSettings(context, newAccount)
for ((authority, interval) in syncIntervals) {
if (interval == null)
ContentResolver.setIsSyncable(newAccount, authority, 0)
else {
ContentResolver.setIsSyncable(newAccount, authority, 1)
newSettings.setSyncInterval(authority, interval)
}
}
} finally {
// release AccountsCleanupWorker mutex at the end of this async coroutine
AccountsCleanupWorker.unlockAccountsCleanup()
}
}
// helpers
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = serviceRepository.insertOrReplace(service)
// insert home sets
for (homeSet in info.homeSets)
homeSetRepository.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionRepository.insertOrUpdateByUrl(collection)
}
return serviceId
}
}

View File

@@ -1,50 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import androidx.room.Transaction
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import javax.inject.Inject
class DavHomeSetRepository @Inject constructor(
db: AppDatabase
) {
val dao = db.homeSetDao()
fun getAddressBookHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV)
fun getBindableByServiceFlow(serviceId: Long) = dao.getBindableByServiceFlow(serviceId)
fun getById(id: Long) = dao.getById(id)
fun getByService(serviceId: Long) = dao.getByService(serviceId)
fun getCalendarHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrl(homeset: HomeSet): Long =
dao.getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
dao.update(homeset.copy(id = existingHomeset.id))
existingHomeset.id
} ?: dao.insert(homeset)
fun delete(homeSet: HomeSet) = dao.delete(homeSet)
}

View File

@@ -1,60 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.app.Application
import android.content.pm.PackageManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.text.Collator
import javax.inject.Inject
class DavSyncStatsRepository @Inject constructor(
val context: Application,
db: AppDatabase
) {
private val dao = db.syncStatsDao()
data class LastSynced(
val appName: String,
val lastSynced: Long
)
fun getLastSyncedFlow(collectionId: Long): Flow<List<LastSynced>> =
dao.getByCollectionIdFlow(collectionId).map { list ->
val collator = Collator.getInstance()
list.map { stats ->
LastSynced(
appName = appNameFromAuthority(stats.authority),
lastSynced = stats.lastSync
)
}.sortedWith { a, b ->
collator.compare(a.appName, b.appName)
}
}
/**
* Tries to find the application name for given authority. Returns the authority if not
* found.
*
* @param authority authority to find the application name for (ie "at.techbee.jtx")
* @return the application name of authority (ie "jtx Board")
*/
private fun appNameFromAuthority(authority: String): String {
val packageManager = context.packageManager
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
packageManager.getApplicationLabel(appInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
Logger.log.warning("Application name not found for authority: $authority")
authority
}
}
}

View File

@@ -1,452 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.os.RemoteException
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 at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.AccountUtils
import at.bitfire.davdroid.util.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.GroupMethod
import java.io.ByteArrayOutputStream
import java.util.LinkedList
import java.util.logging.Level
/**
* 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.
*/
open class LocalAddressBook(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
companion object {
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.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")
}
override val tag: String
get() = "contacts-${account.name}"
override val title = account.name!!
/**
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
*
* For instance, if groupMethod is GROUP_VCARDS, [findDirty] will find only dirty [LocalContact]s,
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
open val groupMethod: GroupMethod by lazy {
val accountSettings = AccountSettings(context, requireMainAccount())
accountSettings.getGroupMethod()
}
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")
var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setAndVerifyUserData(account, 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)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
set(state) {
syncState = state?.toString()?.toByteArray()
}
/* operations on the collection (address book) itself */
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider!!.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
}
return number
}
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider!!.delete(groupsSyncUri(),
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
/**
* 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]
*/
fun update(info: Collection, forceReadOnly: Boolean) {
val newAccountName = accountName(requireMainAccount(), info)
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
}
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
// update address book itself
readOnly = nowReadOnly
// 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 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()
}
fun delete() {
val accountManager = AccountManager.get(context)
accountManager.removeAccount(account, null, null, null)
}
/**
* Updates the sync framework settings for this address book:
*
* - Contacts sync of this address book account shall be possible -> isSyncable = 1
* - When a contact is changed, a sync shall be initiated -> syncAutomatically = true
* - Remove unwanted sync framework periodic syncs created by setSyncAutomatically, as
* we use PeriodicSyncWorker for scheduled syncs
*/
fun updateSyncFrameworkSettings() {
// Enable sync-ability
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
// Enable content trigger
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
}
/* operations on members (contacts/groups) */
override fun findByName(name: String): LocalAddress? {
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
return if (includeGroups)
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
else
result
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
* @throws RemoteException on content provider errors
*/
override fun findDeleted() =
if (includeGroups)
findDeletedContacts() + findDeletedGroups()
else
findDeletedContacts()
fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null)
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
* @throws RemoteException on content provider errors
*/
override fun findDirty() =
if (includeGroups)
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
override fun forgetETags() {
if (includeGroups) {
val values = ContentValues(1)
values.putNull(AndroidGroup.COLUMN_ETAG)
provider!!.update(groupsSyncUri(), values, null, null)
}
val values = ContentValues(1)
values.putNull(AndroidContact.COLUMN_ETAG)
provider!!.update(rawContactsSyncUri(), values, null, null)
}
fun getContactIdsByGroupMembership(groupId: Long): List<Long> {
val ids = LinkedList<Long>()
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID),
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor ->
while (cursor.moveToNext())
ids += cursor.getLong(0)
}
return ids
}
fun getContactUidFromId(contactId: Long): String? {
provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID),
"${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getString(0)
}
return null
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
* @throws RemoteException on content provider errors
*/
fun verifyDirty(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("verifyDirty() should not be called on Android != 7.0")
var reallyDirty = 0
for (contact in findDirtyContacts()) {
val lastHash = contact.getLastHashCode()
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
if (includeGroups)
reallyDirty += findDirtyGroups().size
return reallyDirty
}
/* special group operations */
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws RemoteException on content provider errors
*/
fun findOrCreateGroup(title: String): Long {
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getLong(0)
}
val values = ContentValues(1)
values.put(Groups.TITLE, title)
val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
return ContentUris.parseId(uri)
}
fun removeEmptyGroups() {
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
Logger.log.log(Level.FINE, "Deleting group", group)
group.delete()
}
}
}

View File

@@ -1,296 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import java.util.LinkedList
import java.util.logging.Level
class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, 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)
values.put(Calendars.ACCOUNT_TYPE, account.type)
// Email address for scheduling. Used by the calendar provider to determine whether the
// user is ORGANIZER/ATTENDEE for a certain event.
values.put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
return create(account, provider, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url.toString())
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 (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
} else
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
info.timezone?.let { tzData ->
try {
val timeZone = DateUtils.parseVTimeZone(tzData)
timeZone.timeZoneId?.let { tzId ->
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
}
} catch(e: IllegalArgumentException) {
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}
}
override val tag: String
get() = "events-${account.name}-$id"
override val title: String
get() = displayName ?: id.toString()
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
set(state) {
val values = ContentValues(1)
values.put(COLUMN_SYNC_STATE, state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
override fun populate(info: ContentValues) {
super.populate(info)
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
/*
* RFC 5545 3.8.7.4. Sequence Number
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
* CUA each time the "Organizer" makes a significant revision to the calendar component.
*/
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
try {
val event = requireNotNull(localEvent.event)
val nonGroupScheduled = event.attendees.isEmpty()
val weAreOrganizer = localEvent.weAreOrganizer
val sequence = event.sequence
if (sequence == null)
// sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
event.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
dirty += localEvent
}
return dirty
}
override fun findByName(name: String) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int): Int {
var deleted = 0
// list all non-dirty events with the given flags and delete every row + its exceptions
provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
val batch = BatchOperation(provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(Events.CONTENT_URI.asSyncAdapter(account))
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
}
deleted = batch.commit()
}
return deleted
}
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.toString()))
}
fun processDirtyExceptions() {
// process deleted exceptions
Logger.log.info("Processing deleted exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val batch = BatchOperation(provider)
// get original event's SEQUENCE
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
// re-schedule original event and set it to DIRTY
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1))
}
}
// completely remove deleted exception
batch.enqueue(BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account)))
batch.commit()
}
}
// process dirty exceptions
Logger.log.info("Processing dirty exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
val batch = BatchOperation(provider)
// original event to DIRTY
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(Events.DIRTY, 1))
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0))
batch.commit()
}
}
}
/**
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
*
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events)
null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val eventID = cursor.getLong(0)
// get number of instances
val numEventInstances = LocalEvent.numInstances(provider, account, eventID)
// delete event if there are no instances
if (numEventInstances == 0) {
Logger.log.info("Marking event #$eventID without instances as deleted")
LocalEvent.markAsDeleted(provider, account, eventID)
}
}
}
}
object Factory: AndroidCalendarFactory<LocalCalendar> {
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
LocalCalendar(account, provider, id)
}
}

View File

@@ -1,250 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentValues
import android.os.Build
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.UUID
class LocalContact: AndroidContact, LocalAddress {
override val addressBook: LocalAddressBook
get() = super.addressBook as LocalAddressBook
companion object {
init {
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
}
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
internal val cachedGroupMemberships = HashSet<Long>()
internal val groupMemberships = HashSet<Long>()
override var scheduleTag: String?
get() = null
set(_) = throw NotImplementedError()
override var flags: Int = 0
constructor(addressBook: LocalAddressBook, values: ContentValues): super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: LocalAddressBook, contact: Contact, fileName: String?, eTag: String?, _flags: Int): super(addressBook, contact, fileName, eTag) {
flags = _flags
}
init {
processor.registerHandler(CachedGroupMembershipHandler(this))
processor.registerHandler(GroupMembershipHandler(this))
processor.registerHandler(UnknownPropertiesHandler)
processor.registerBuilderFactory(GroupMembershipBuilder.Factory(addressBook))
processor.registerBuilderFactory(UnknownPropertiesBuilder.Factory)
}
override fun prepareForUpload(): String {
val contact = getContact()
val uid: String = contact.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in contacts provider
val values = ContentValues(1)
values.put(COLUMN_UID, newUid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
// update this event
contact.uid = newUid
newUid
}
return "$uid.vcf"
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
val values = ContentValues(4)
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.flags = flags
}
/**
* Calculates a hash code from the contact's data (VCard) and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
* @return hash code of contact data (including group memberships)
*/
internal fun dataHashCode(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
// reset contact so that getContact() reads from database
_contact = null
// groupMemberships is filled by getContact()
val dataHash = getContact().hashCode()
val groupHash = groupMemberships.hashCode()
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
return dataHash xor groupHash
}
fun updateHashCode(batch: BatchOperation?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
val hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
if (batch == null) {
val values = ContentValues(1)
values.put(COLUMN_HASHCODE, hashCode)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} else
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode))
}
fun getLastHashCode(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
}
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID))
groupMemberships += groupID
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
)
cachedGroupMemberships += groupID
}
fun removeGroupMemberships(batch: BatchOperation) {
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(dataSyncURI())
.withSelection(
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
))
groupMemberships.clear()
cachedGroupMemberships.clear()
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
* whether a membership has been deleted/added when a raw contact is dirty.
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
* @throws FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
fun getCachedGroupMemberships(): Set<Long> {
getContact()
return cachedGroupMemberships
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
fun getGroupMemberships(): Set<Long> {
getContact()
return groupMemberships
}
// data rows
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
LocalContact(addressBook as LocalAddressBook, values)
}
}

View File

@@ -1,270 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.BuildConfig
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.AndroidEventFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import net.fortuna.ical4j.model.property.ProdId
import java.util.UUID
class LocalEvent: AndroidEvent, LocalResource<Event> {
companion object {
init {
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
}
const val COLUMN_ETAG = Events.SYNC_DATA1
const val COLUMN_FLAGS = Events.SYNC_DATA2
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
/**
* Marks the event as deleted
* @param eventID
*/
fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) {
provider.update(
ContentUris.withAppendedId(
Events.CONTENT_URI,
eventID
).asSyncAdapter(account),
ContentValues(1).apply {
put(Events.DELETED, 1)
},
null,null
)
}
/**
* Finds the amount of direct instances this event has (without exceptions); used by [numInstances]
* to find the number of instances of exceptions.
*
* The number of returned instances may vary with the Android version.
*
* @return number of direct event instances (not counting instances of exceptions); *null* if
* the number can't be determined or if the event has no last date (recurring event without last instance)
*/
fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
// query event to get first and last instance
var first: Long? = null
var last: Long? = null
provider.query(
ContentUris.withAppendedId(
Events.CONTENT_URI,
eventID
),
arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null
)?.use { cursor ->
cursor.moveToNext()
if (!cursor.isNull(0))
first = cursor.getLong(0)
if (!cursor.isNull(1))
last = cursor.getLong(1)
}
// if this event doesn't have a last occurence, it's endless and always has instances
if (first == null || last == null)
return null
/* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances
on the fly and it doesn't accept those values. So we use the first/last actual occurence
of the event (calculated by Android). */
val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account)
.buildUpon()
.appendPath(first.toString()) // begin timestamp
.appendPath(last.toString()) // end timestamp
.build()
var numInstances = 0
provider.query(
instancesUri, null,
"${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()),
null
)?.use { cursor ->
numInstances += cursor.count
}
return numInstances
}
/**
* Finds the total number of instances this event has (including instances of exceptions)
*
* The number of returned instances may vary with the Android version.
*
* @return number of direct event instances (not counting instances of exceptions); *null* if
* the number can't be determined or if the event has no last date (recurring event without last instance)
*/
fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
// num instances of the main event
var numInstances = numDirectInstances(provider, account, eventID) ?: return null
// add the number of instances of every main event's exception
provider.query(
Events.CONTENT_URI,
arrayOf(Events._ID),
"${Events.ORIGINAL_ID}=?", // get exception events of the main event
arrayOf("$eventID"), null
)?.use { exceptionsEventCursor ->
while (exceptionsEventCursor.moveToNext()) {
val exceptionEventID = exceptionsEventCursor.getLong(0)
val exceptionInstances = numDirectInstances(provider, account, exceptionEventID)
if (exceptionInstances == null)
// number of instances of exception can't be determined; so the total number of instances is also unclear
return null
numInstances += exceptionInstances
}
}
return numInstances
}
}
override var fileName: String? = null
private set
override var eTag: String? = null
override var scheduleTag: String? = null
override var flags: Int = 0
private set
var weAreOrganizer = false
private set
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
val event = requireNotNull(event)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
super.populateEvent(row, groupScheduled)
}
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
super.buildEvent(recurrence, builder)
}
/**
* Creates and sets a new UID in the calendar provider, if no UID is already set.
* It also returns the desired file name for the event for further processing in the sync algorithm.
*
* @return file name to use at upload
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = event!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in calendar provider
val values = ContentValues(1)
values.put(Events.UID_2445, newUid)
calendar.provider.update(eventSyncURI(), values, null, null)
// update this event
event?.uid = newUid
newUid
}
val uidIsGoodFilename = uid.all { char ->
// see RFC 2396 2.2
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?'
'-','_','.','!','~','*','\'','(',')' // allow unreserved characters
).contains(char)
}
return if (uidIsGoodFilename)
"$uid.ics" // use UID as file name
else
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
val values = ContentValues(5)
if (fileName != null)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
values.put(Events.DIRTY, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
}
override fun resetDeleted() {
val values = ContentValues(1).apply { put(Events.DELETED, 0) }
calendar.provider.update(eventSyncURI(), values, null, null)
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
}

View File

@@ -1,114 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxCollectionFactory
import at.bitfire.ical4android.JtxICalObject
import at.techbee.jtx.JtxContract
import java.util.logging.Level
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
LocalCollection<LocalJtxICalObject>{
companion object {
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) {
val values = valuesFromCollection(info, account, owner, true)
create(account, client, values)
}
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
ContentValues().apply {
put(JtxContract.JtxCollection.URL, info.url.toString())
put(
JtxContract.JtxCollection.DISPLAYNAME,
info.displayName ?: info.url.lastSegment
)
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
if (owner != null)
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
else Logger.log.log(Level.SEVERE, "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)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
}
}
override val readOnly: Boolean
get() = throw NotImplementedError()
override val tag: String
get() = "jtx-${account.name}-$id"
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)
update(values)
}
override fun findDeleted(): List<LocalJtxICalObject> {
val values = queryDeletedICalObjects()
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
values.forEach {
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
}
return localJtxICalObjects
}
override fun findDirty(): List<LocalJtxICalObject> {
val values = queryDirtyICalObjects()
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
values.forEach {
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
}
return localJtxICalObjects
}
override fun findByName(name: String): LocalJtxICalObject? {
val values = queryByFilename(name) ?: return null
return LocalJtxICalObject.Factory.fromProvider(this, values)
}
/**
* Finds and returns a recurring instance of a [LocalJtxICalObject]
*/
fun findRecurring(uid: String, recurid: String, dtstart: Long): LocalJtxICalObject? {
val values = queryRecur(uid, recurid, dtstart) ?: return null
return LocalJtxICalObject.Factory.fromProvider(this, values)
}
override fun markNotDirty(flags: Int)= updateSetFlags(flags)
override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags)
override fun forgetETags() = updateSetETag(null)
object Factory: JtxCollectionFactory<LocalJtxCollection> {
override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id)
}
}

View File

@@ -1,98 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.net.Uri
interface LocalResource<in TData: Any> {
companion object {
/**
* Resource is present on remote server. This flag is used to identify resources
* which are not present on the remote server anymore and can be deleted at the end
* of the synchronization.
*/
const val FLAG_REMOTELY_PRESENT = 1
}
/**
* Unique ID which identifies the resource in the local storage. May be null if the
* resource has not been saved yet.
*/
val id: Long?
/**
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
* (in this case, [fileName] is the remote file name).
*/
val fileName: String?
/** remote ETag for the resource */
var eTag: String?
/** remote Schedule-Tag for the resource */
var scheduleTag: String?
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
val flags: Int
/**
* Prepares the resource for uploading:
*
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
* saved to the content provider. The sync manager is responsible for saving the file name that
* was actually used.
*
* @return new file name of the resource (like "<uid>.vcf")
*/
fun prepareForUpload(): String
/**
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
* locally modified resource.
*
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
*/
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
/**
* Sets (local) flags of the resource. At the moment, the only allowed values are
* 0 and [FLAG_REMOTELY_PRESENT].
*/
fun updateFlags(flags: Int)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the created row (e.g. event URI)
*/
fun add(): Uri
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData): Uri
/**
* Deletes the data object from the content provider.
*
* @return number of affected rows
*/
fun delete(): Int
/**
* Undoes deletion of the data object from the content provider.
*/
fun resetDeleted()
}

View File

@@ -1,116 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentValues
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.Task
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.UUID
class LocalTask: DmfsTask, LocalResource<Task> {
companion object {
const val COLUMN_ETAG = Tasks.SYNC1
const val COLUMN_FLAGS = Tasks.SYNC2
}
override var fileName: String? = null
override var scheduleTag: String? = null
override var eTag: String? = null
override var flags = 0
private set
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
id = values.getAsLong(Tasks._ID)
fileName = values.getAsString(Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/* process LocalTask-specific fields */
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
super.buildTask(builder, update)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_FLAGS, flags)
}
/* custom queries */
override fun prepareForUpload(): String {
val uid: String = task!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in tasks provider
val values = ContentValues(1)
values.put(Tasks._UID, newUid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
// update this task
task!!.uid = newUid
newUid
}
return "$uid.ics"
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
Ical4Android.log.fine("Schedule-Tag for tasks not supported yet, won't save")
val values = ContentValues(4)
if (fileName != null)
values.put(Tasks._SYNC_ID, fileName)
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)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
taskList.provider.client.update(taskSyncURI(), values, null, null)
}
this.flags = flags
}
override fun resetDeleted() {
throw NotImplementedError()
}
object Factory: DmfsTaskFactory<LocalTask> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}

View File

@@ -1,166 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
import org.dmfs.tasks.contract.TaskContract.*
import java.util.logging.Level
class LocalTaskList private constructor(
account: Account,
provider: TaskProvider,
id: Long
): DmfsTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
companion object {
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
values.put(TaskLists.VISIBLE, 1)
return create(account, provider, values)
}
@SuppressLint("Recycle")
@Throws(Exception::class)
fun onRenameAccount(context: Context, oldName: String, newName: String) {
TaskProvider.acquire(context)?.use { provider ->
val values = ContentValues(1)
values.put(Tasks.ACCOUNT_NAME, newName)
provider.client.update(
Tasks.getContentUri(provider.name.authority),
values,
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
)
}
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url.toString())
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 (info.privWriteContent && !info.forceReadOnly)
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
else
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
return values
}
}
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
override val readOnly
get() =
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
override val tag: String
get() = "tasks-${account.name}-$id"
override val title: String
get() = name ?: id.toString()
override var lastSyncState: SyncState?
get() {
try {
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
provider.client.update(taskListSyncUri(), values, null, null)
}
override fun populate(values: ContentValues) {
super.populate(values)
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks(Tasks._DIRTY, null)
for (localTask in tasks) {
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else // task was modified, increase sequence
task.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
}
return tasks
}
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalTask.COLUMN_FLAGS, flags)
return provider.client.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.client.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}=?",
arrayOf(id.toString()))
}
object Factory: DmfsTaskListFactory<LocalTaskList> {
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
LocalTaskList(account, provider, id)
}
}

View File

@@ -1,364 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GroupMembership
import at.bitfire.dav4jvm.property.webdav.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.DavUtils.parent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.logging.Level
/**
* Logic for refreshing the list of collections and home-sets and related information.
*/
class CollectionListRefresher @AssistedInject constructor(
@Assisted val service: Service,
@Assisted val httpClient: OkHttpClient,
private val db: AppDatabase,
private val homeSetRepository: DavHomeSetRepository,
private val collectionRepository: DavCollectionRepository,
private val settings: SettingsManager
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
}
private val alreadyQueried = mutableSetOf<HttpUrl>()
/**
* Starting at current-user-principal URL, tries to recursively find and save all user relevant home sets.
*
*
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
* @param level Current recursion level (limited to 0, 1 or 2):
*
* - 0: We assume found home sets belong to the current-user-principal
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
*/
internal fun discoverHomesets(principalUrl: HttpUrl, level: Int = 0) {
Logger.log.fine("Discovering homesets of $principalUrl")
val relatedResources = mutableSetOf<HttpUrl>()
// Define homeset class and properties to look for
val homeSetClass: Class<out HrefListProperty>
val properties: Array<Property.Name>
when (service.type) {
Service.TYPE_CARDDAV -> {
homeSetClass = AddressbookHomeSet::class.java
properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME, ResourceType.NAME)
}
Service.TYPE_CALDAV -> {
homeSetClass = CalendarHomeSet::class.java
properties = arrayOf(
DisplayName.NAME,
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME,
GroupMembership.NAME,
ResourceType.NAME
)
}
else -> throw IllegalArgumentException()
}
// Query the URL
val principal = DavResource(httpClient, principalUrl)
val personal = level == 0
try {
principal.propfind(0, *properties) { davResponse, _ ->
alreadyQueried += davResponse.href
// If response holds home sets, save them
davResponse[homeSetClass]?.let { homeSets ->
for (homeSetHref in homeSets.hrefs)
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
// Homeset is considered personal if this is the outer recursion call,
// This is because we assume the first call to query the current-user-principal
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
// other principals and still be considered "personal" (belonging to the current-user-principal).
homeSetRepository.insertOrUpdateByUrl(
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
)
}
}
// Add related principals to be queried afterwards
if (personal) {
val relatedResourcesTypes = listOf(
// current resource is a read/write-proxy for other principals
CalendarProxyReadFor::class.java,
CalendarProxyWriteFor::class.java,
// current resource is a member of a group (principal that can also have proxies)
GroupMembership::class.java
)
for (type in relatedResourcesTypes)
davResponse[type]?.let {
for (href in it.hrefs)
principal.location.resolve(href)?.let { url ->
relatedResources += url
}
}
}
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
davResponse[ResourceType::class.java]?.let { resourceType ->
val proxyProperties = arrayOf(
ResourceType.CALENDAR_PROXY_READ,
ResourceType.CALENDAR_PROXY_WRITE,
)
if (proxyProperties.any { resourceType.types.contains(it) })
relatedResources += davResponse.href.parent()
}
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e
}
// query related resources
if (level <= 1)
for (resource in relatedResources)
if (alreadyQueried.contains(resource))
Logger.log.warning("$resource already queried, skipping")
else
discoverHomesets(resource, level + 1)
}
/**
* Refreshes home-sets and their collections.
*
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
* or marked as homeless - in case a collection was removed from its home-set.
*
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [refreshHomelessCollections].
*/
internal fun refreshHomesetsAndTheirCollections() {
val homesets = homeSetRepository.getByService(service.id).associateBy { it.url }.toMutableMap()
for((homeSetUrl, localHomeset) in homesets) {
Logger.log.fine("Listing home set $homeSetUrl")
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
// is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless.
val localHomesetCollections = db.collectionDao()
.getByServiceAndHomeset(service.id, localHomeset.id)
.associateBy { it.url }
.toMutableMap()
try {
DavResource(httpClient, homeSetUrl).propfind(1, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, relation ->
// Note: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
localHomeset.displayName = response[DisplayName::class.java]?.displayName
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
homeSetRepository.insertOrUpdateByUrl(localHomeset)
}
// in any case, check whether the response is about a usable collection
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.serviceId = service.id
collection.homeSetId = localHomeset.id
collection.sync = shouldPreselect(collection, homesets.values)
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
?.let { response.href.resolve(it) }
?.let { principalUrl ->
val principal = Principal.fromServiceAndUrl(service, principalUrl)
val id = db.principalDao().insertOrUpdate(service.id, principal)
collection.ownerId = id
}
Logger.log.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
if (isUsableCollection(collection))
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection)
// Remove this collection from queue - because it was found in the home set
localHomesetCollections.remove(collection.url)
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
homeSetRepository.delete(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
for ((_, homelessCollection) in localHomesetCollections) {
homelessCollection.homeSetId = null
collectionRepository.insertOrUpdateByUrlAndRememberFlags(homelessCollection)
}
}
}
/**
* Refreshes collections which don't have a homeset.
*
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
*/
internal fun refreshHomelessCollections() {
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for((url, localCollection) in homelessCollections) try {
DavResource(httpClient, url).propfind(0, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess()) {
collectionRepository.delete(localCollection)
return@propfind
}
// Save or update the collection, if usable, otherwise delete it
Collection.fromDavResponse(response)?.let { collection ->
if (!isUsableCollection(collection))
return@let
collection.serviceId = localCollection.serviceId // use same service ID as previous entry
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
?.let { response.href.resolve(it) }
?.let { principalUrl ->
val principal = Principal.fromServiceAndUrl(service, principalUrl)
val principalId = db.principalDao().insertOrUpdate(service.id, principal)
collection.ownerId = principalId
}
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection)
} ?: collectionRepository.delete(localCollection)
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
/**
* Refreshes the principals (get their current display names).
* Also removes principals which do not own any collections anymore.
*/
internal fun refreshPrincipals() {
// Refresh principals (collection owner urls)
val principals = db.principalDao().getByService(service.id)
for (oldPrincipal in principals) {
val principalUrl = oldPrincipal.url
Logger.log.fine("Querying principal $principalUrl")
try {
DavResource(httpClient, principalUrl).propfind(0, *RefreshCollectionsWorker.DAV_PRINCIPAL_PROPERTIES) { response, _ ->
if (!response.isSuccess())
return@propfind
Principal.fromDavResponse(service.id, response)?.let { principal ->
Logger.log.fine("Got principal: $principal")
db.principalDao().insertOrUpdate(service.id, principal)
}
}
} catch (e: HttpException) {
Logger.log.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach {principal ->
db.principalDao().delete(principal)
}
}
/**
* Finds out whether given collection is usable, by checking that either
* - CalDAV/CardDAV: service and collection type match, or
* - WebCal: subscription source URL is not empty
*/
private fun isUsableCollection(collection: Collection) =
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
/**
* Whether to preselect the given collection for synchronisation, according to the
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
*
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
*
* Before a collection is pre-selected, we check whether its URL matches the regexp in
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homesets list of home-sets (to check whether collection is in a personal home-set)
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homesets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
if (!excludedRegex.isNullOrEmpty())
Regex(excludedRegex).containsMatchIn(collection.url.toString())
else
false
}
return when (shouldPreselect) {
Settings.PRESELECT_COLLECTIONS_ALL ->
// preselect if collection url is not excluded
!excluded
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homesets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)
&& !excluded
else -> // don't preselect
false
}
}
}

View File

@@ -1,519 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
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.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import net.openid.appauth.AuthState
import org.apache.commons.lang3.StringUtils
import java.util.logging.Level
/**
* Manages settings of an account.
*
* @param context Required to access account settings
* @param argAccount Account to take settings from. If this account is an address book account,
* settings will be taken from the corresponding main account instead.
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
* @throws IllegalArgumentException when the account type is not _DAVx5_ or _DAVx5 address book_
*/
class AccountSettings(
val context: Context,
argAccount: Account
) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AccountSettingsEntryPoint {
fun migrationsFactory(): AccountSettingsMigrations.Factory
fun settingsManager(): SettingsManager
}
companion object {
const val CURRENT_VERSION = 15
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
}
}
private val entryPoint = EntryPointAccessors.fromApplication<AccountSettingsEntryPoint>(context)
private val settings = entryPoint.settingsManager()
val accountManager: AccountManager = AccountManager.get(context)
val account: Account
init {
account = when (argAccount.type) {
context.getString(R.string.account_type_address_book) -> {
/* argAccount 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, argAccount) ?: throw IllegalArgumentException("Main account of $argAccount not found")
}
context.getString(R.string.account_type) ->
argAccount
else ->
throw IllegalArgumentException("Account type ${argAccount.type} not supported")
}
// synchronize because account migration must only be run one time
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
Logger.log.log(Level.SEVERE, "Invalid account version: $versionStr", e)
}
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION) {
if (currentlyUpdating) {
Logger.log.severe("Redundant call: migration created AccountSettings(). This must never happen.")
throw IllegalStateException("Redundant call: migration created AccountSettings()")
} else {
currentlyUpdating = true
update(version)
currentlyUpdating = false
}
}
}
}
// authentication settings
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
accountManager.getUserData(account, KEY_AUTH_STATE)?.let { json ->
AuthState.jsonDeserialize(json)
}
)
fun credentials(credentials: Credentials) {
// Basic/Digest auth
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
accountManager.setPassword(account, credentials.password)
// client certificate
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
// OAuth
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString())
}
// sync. settings
/**
* Gets the currently set sync interval for this account in seconds.
*
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
* @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set
*/
fun getSyncInterval(authority: String): Long? {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null
val key = when {
authority == context.getString(R.string.address_books_authority) ->
KEY_SYNC_INTERVAL_ADDRESSBOOKS
authority == CalendarContract.AUTHORITY ->
KEY_SYNC_INTERVAL_CALENDARS
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
KEY_SYNC_INTERVAL_TASKS
else -> return null
}
return accountManager.getUserData(account, key)?.toLong()
}
fun getTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
/**
* Sets the sync interval and en- or disables periodic sync for the given account and authority.
* Does *not* call [ContentResolver.setIsSyncable].
*
* This method blocks until a worker as been created and enqueued (sync active) or removed
* (sync disabled), so it should not be called from the UI thread.
*
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @param _seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
* otherwise (must be ≥ 15 min): automatic sync will be enabled and set to the given number of seconds
*/
@WorkerThread
fun setSyncInterval(authority: String, _seconds: Long) {
val seconds =
if (_seconds != SYNC_INTERVAL_MANUALLY && _seconds < 60*15)
60*15
else
_seconds
// Store (user defined) sync interval in account settings
val key = when {
authority == context.getString(R.string.address_books_authority) ->
KEY_SYNC_INTERVAL_ADDRESSBOOKS
authority == CalendarContract.AUTHORITY ->
KEY_SYNC_INTERVAL_CALENDARS
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
KEY_SYNC_INTERVAL_TASKS
else -> {
Logger.log.warning("Sync interval not applicable to authority $authority")
return
}
}
accountManager.setAndVerifyUserData(account, key, seconds.toString())
// update sync workers (needs already updated sync interval in AccountSettings)
updatePeriodicSyncWorker(authority, seconds, getSyncWifiOnly())
// Also enable/disable content change triggered syncs (SyncFramework automatic sync).
// We could make this a separate user adjustable setting later on.
setSyncOnContentChange(authority, seconds != SYNC_INTERVAL_MANUALLY)
}
/**
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
*
* We use the sync adapter framework only for the trigger, actual syncing is implemented
* with WorkManager. The trigger comes in through SyncAdapterService.
*
* This method blocks until the sync-on-content-change has been enabled or disabled, so it
* should not be called from the UI thread.
*
* @param enable *true* enables automatic sync; *false* disables it
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @return whether the content triggered sync was enabled successfully
*/
@WorkerThread
fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean {
// Enable content change triggers (sync adapter framework)
val setContentTrigger: () -> Boolean =
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
if (enable) {{
Logger.log.fine("Enabling content-triggered sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers
// Remove unwanted sync framework periodic syncs created by setSyncAutomatically
for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
/* return */ ContentResolver.getSyncAutomatically(account, authority)
}} else {{
Logger.log.fine("Disabling content-triggered sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
}}
// try up to 10 times with 100 ms pause
for (idxTry in 0 until 10) {
if (setContentTrigger())
// successfully set
return true
Thread.sleep(100)
}
return false
}
fun getSyncWifiOnly() =
if (settings.containsKey(KEY_WIFI_ONLY))
settings.getBoolean(KEY_WIFI_ONLY)
else
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
// update sync workers (needs already updated wifi-only flag in AccountSettings)
for (authority in SyncUtils.syncAuthorities(context))
updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly)
}
fun getSyncWifiOnlySSIDs(): List<String>? =
if (getSyncWifiOnly()) {
val strSsids = if (settings.containsKey(KEY_WIFI_ONLY_SSIDS))
settings.getString(KEY_WIFI_ONLY_SSIDS)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS)
strSsids?.split(',')
} else
null
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
fun getIgnoreVpns(): Boolean =
when (accountManager.getUserData(account, KEY_IGNORE_VPNS)) {
null -> settings.getBoolean(KEY_IGNORE_VPNS)
"0" -> false
else -> true
}
fun setIgnoreVpns(ignoreVpns: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0")
/**
* Updates the periodic sync worker of an authority according to
*
* - the sync interval and
* - the _Sync WiFi only_ flag.
*
* @param authority periodic sync workers for this authority will be updated
* @param seconds sync interval in seconds (`null` or [SYNC_INTERVAL_MANUALLY] disables periodic sync)
* @param wiFiOnly sync Wifi only flag
*/
fun updatePeriodicSyncWorker(authority: String, seconds: Long?, wiFiOnly: Boolean) {
try {
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
Logger.log.fine("Disabling periodic sync of $account/$authority")
PeriodicSyncWorker.disable(context, account, authority)
} else {
Logger.log.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
PeriodicSyncWorker.enable(context, account, authority, seconds, wiFiOnly)
}.result.get() // On operation (enable/disable) failure exception is thrown
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
}
}
// CalDAV settings
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
return if (strDays != null) {
val days = strDays.toInt()
if (days < 0)
null
else
days
} else
DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
/**
* Takes the default alarm setting (in this order) from
*
* 1. the local account settings
* 2. the settings provider (unless the value is -1 there).
*
* @return A default reminder shall be created this number of minutes before the start of every
* non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun getDefaultAlarm() =
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
/**
* Sets the default alarm value in the local account settings, if the new value differs
* from the value of the settings provider. If the new value is the same as the value of
* the settings provider, the local setting will be deleted, so that the settings provider
* value applies.
*
* @param minBefore The number of minutes a default reminder shall be created before the
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun setDefaultAlarm(minBefore: Int?) =
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
if (minBefore == settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
null
else
minBefore?.toString())
fun getManageCalendarColors() = if (settings.containsKey(KEY_MANAGE_CALENDAR_COLORS))
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS)
else
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
fun setManageCalendarColors(manage: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
fun getEventColors() = if (settings.containsKey(KEY_EVENT_COLORS))
settings.getBoolean(KEY_EVENT_COLORS)
else
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
fun setEventColors(useColors: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
if (name != null)
try {
return GroupMethod.valueOf(name)
}
catch (_: IllegalArgumentException) {
}
return GroupMethod.GROUP_VCARDS
}
fun setGroupMethod(method: GroupMethod) {
accountManager.setAndVerifyUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
}
// UI settings
data class ShowOnlyPersonal(
val onlyPersonal: Boolean,
val locked: Boolean
)
fun getShowOnlyPersonal(): ShowOnlyPersonal {
@Suppress("DEPRECATION")
val pair = getShowOnlyPersonalPair()
return ShowOnlyPersonal(onlyPersonal = pair.first, locked = !pair.second)
}
/**
* Whether only personal collections should be shown.
*
* @return [Pair] of values:
*
* 1. (first) whether only personal collections should be shown
* 2. (second) whether the user shall be able to change the setting (= setting not locked)
*/
@Deprecated("Use getShowOnlyPersonal() instead", replaceWith = ReplaceWith("getShowOnlyPersonal()"))
fun getShowOnlyPersonalPair(): Pair<Boolean, Boolean> =
when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
0 -> Pair(false, false)
1 -> Pair(true, false)
else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true)
}
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) {
accountManager.setAndVerifyUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null)
}
// update from previous account settings
private fun update(baseVersion: Int) {
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
val fromVersion = toVersion-1
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
try {
val migrationsFactory = entryPoint.migrationsFactory()
val migrations = migrationsFactory.create(
account = account,
accountSettings = this
)
val updateProc = AccountSettingsMigrations::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
updateProc.invoke(migrations)
Logger.log.info("Account version update successful")
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
}
}
}
}

View File

@@ -1,431 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.app.Application
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
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
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
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.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.util.TaskUtils
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.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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
import java.util.logging.Level
class AccountSettingsMigrations @AssistedInject constructor(
@Assisted val account: Account,
@Assisted val accountSettings: AccountSettings,
val context: Application,
val db: AppDatabase,
val settings: SettingsManager
) {
@AssistedFactory
interface Factory {
fun create(account: Account, accountSettings: AccountSettings): AccountSettingsMigrations
}
val accountManager: AccountManager = AccountManager.get(context)
/**
* Updates the periodic sync workers by re-setting the same sync interval.
*
* The goal is to add the [BaseSyncWorker.commonTag] to all existing periodic sync workers so that they can be detected by
* the new [BaseSyncWorker.exists] and [at.bitfire.davdroid.ui.AccountsActivity.Model].
*/
@Suppress("unused","FunctionName")
fun update_14_15() {
for (authority in SyncUtils.syncAuthorities(context)) {
val interval = accountSettings.getSyncInterval(authority)
accountSettings.setSyncInterval(authority, interval ?: AccountSettings.SYNC_INTERVAL_MANUALLY)
}
}
/**
* Disables all sync adapter periodic syncs for every authority. Then enables
* corresponding PeriodicSyncWorkers
*/
@Suppress("unused","FunctionName")
fun update_13_14() {
// Cancel any potentially running syncs for this account (sync framework)
ContentResolver.cancelSync(account, null)
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
for (authority in authorities) {
// Enable PeriodicSyncWorker (WorkManager), with known intervals
v14_enableWorkManager(authority)
// Disable periodic syncs (sync adapter framework)
v14_disableSyncFramework(authority)
}
}
private fun v14_enableWorkManager(authority: String) {
val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval ->
accountSettings.setSyncInterval(authority, syncInterval)
} ?: false
Logger.log.info("PeriodicSyncWorker for $account/$authority enabled=$enabled")
}
private fun v14_disableSyncFramework(authority: String) {
// Disable periodic syncs (sync adapter framework)
val disable: () -> Boolean = {
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
for (sync in ContentResolver.getPeriodicSyncs(account, authority))
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
// check whether syncs are really disabled
var result = true
for (sync in ContentResolver.getPeriodicSyncs(account, authority)) {
Logger.log.info("Sync framework still has a periodic sync for $account/$authority: $sync")
result = false
}
result
}
// try up to 10 times with 100 ms pause
var success = false
for (idxTry in 0 until 10) {
success = disable()
if (success)
break
Thread.sleep(200)
}
Logger.log.info("Sync framework periodic syncs for $account/$authority disabled=$success")
}
@Suppress("unused","FunctionName")
/**
* Not a per-account migration, but not a database migration, too, so it fits best there.
* Best future solution would be that SettingsManager manages versions and migrations.
*
* Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port.
*/
private fun update_12_13() {
// proxy settings are managed by SharedPreferencesProvider
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
// old setting names
val overrideProxy = "override_proxy"
val overrideProxyHost = "override_proxy_host"
val overrideProxyPort = "override_proxy_port"
val edit = preferences.edit()
if (preferences.contains(overrideProxy)) {
if (preferences.getBoolean(overrideProxy, false))
// override_proxy set, migrate to proxy_type = HTTP
edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP)
edit.remove(overrideProxy)
}
if (preferences.contains(overrideProxyHost)) {
preferences.getString(overrideProxyHost, null)?.let { host ->
edit.putString(Settings.PROXY_HOST, host)
}
edit.remove(overrideProxyHost)
}
if (preferences.contains(overrideProxyPort)) {
val port = preferences.getInt(overrideProxyPort, 0)
if (port != 0)
edit.putInt(Settings.PROXY_PORT, port)
edit.remove(overrideProxyPort)
}
edit.apply()
}
@Suppress("unused","FunctionName")
/**
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
* convert legacy unknown properties to the current format.
*/
private fun update_11_12() {
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
// to the given account! So all extended properties will be processed number-of-accounts times.
val extUri = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account)
provider.query(
extUri, arrayOf(
CalendarContract.ExtendedProperties._ID, // idx 0
CalendarContract.ExtendedProperties.NAME, // idx 1
CalendarContract.ExtendedProperties.VALUE // idx 2
), null, null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val rawValue = cursor.getString(2)
val uri by lazy {
ContentUris.withAppendedId(CalendarContract.ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account)
}
when (cursor.getString(1)) {
UnknownProperty.CONTENT_ITEM_TYPE -> {
// unknown property; check whether it's a URL
try {
val property = UnknownProperty.fromJsonString(rawValue)
if (property is Url) { // rewrite to MIMETYPE_URL
val newValues = ContentValues(2)
newValues.put(CalendarContract.ExtendedProperties.NAME, AndroidEvent.EXTNAME_URL)
newValues.put(CalendarContract.ExtendedProperties.VALUE, property.value)
provider.update(uri, newValues, null, null)
}
} catch (e: Exception) {
Logger.log.log(
Level.WARNING,
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
e
)
}
}
"unknown-property" -> {
// unknown property (deprecated format); convert to current format
try {
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
ObjectInputStream(stream).use {
(it.readObject() as? Property)?.let { property ->
// rewrite to current format
val newValues = ContentValues(2)
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
newValues.put(CalendarContract.ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
provider.update(uri, newValues, null, null)
}
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
}
}
"unknown-property.v2" -> {
// unknown property (deprecated MIME type); rewrite to current MIME type
val newValues = ContentValues(1)
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
provider.update(uri, newValues, null, null)
}
}
}
}
}
}
}
@Suppress("unused","FunctionName")
/**
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
* again when the tasks provider is switched.
*/
private fun update_10_11() {
TaskUtils.currentProvider(context)?.let { provider ->
val interval = accountSettings.getSyncInterval(provider.authority)
if (interval != null)
accountManager.setAndVerifyUserData(account,
AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString())
}
}
@Suppress("unused","FunctionName")
/**
* Task synchronization now handles alarms, categories, relations and unknown properties.
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
*
* Also update the allowed reminder types for calendars.
**/
private fun update_9_10() {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
val tasksUri = provider.tasksUri().asSyncAdapter(account)
val emptyETag = ContentValues(1)
emptyETag.putNull(LocalTask.COLUMN_ETAG)
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
}
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
provider.update(
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
AndroidCalendar.calendarBaseValues, null, null)
}
}
@Suppress("unused","FunctionName")
/**
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
* Disable it on those accounts for the future.
*/
private fun update_8_9() {
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) {
Logger.log.info("Disabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
}
}
@Suppress("unused","FunctionName")
@SuppressLint("Recycle")
/**
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
* SEQUENCE and should not be used for the eTag.
*/
private fun update_7_8() {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
// ETag is now in sync_version instead of sync1
// UID is now in _uid instead of sync2
provider.client.query(provider.tasksUri().asSyncAdapter(account),
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
arrayOf(account.type, account.name), null)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
val uid = cursor.getString(2)
val values = ContentValues(4)
values.put(TaskContract.Tasks._UID, uid)
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
values.putNull(TaskContract.Tasks.SYNC1)
values.putNull(TaskContract.Tasks.SYNC2)
Logger.log.log(Level.FINER, "Updating task $id", values)
provider.client.update(
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
values, null, null)
}
}
}
}
@Suppress("unused")
@SuppressLint("Recycle")
private fun update_6_7() {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
AndroidCalendar.insertColors(provider, account)
}
// update allowed WiFi settings key
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID)
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.log.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.log.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.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.log.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.log.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
TaskUtils.selectProvider(context, TaskUtils.currentProvider(context))
}
@Suppress("unused")
private fun update_3_4() {
accountSettings.setGroupMethod(GroupMethod.CATEGORIES)
}
// updates from AccountSettings version 2 and below are not supported anymore
}

View File

@@ -1,65 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.content.Context
import at.bitfire.davdroid.TextTable
import java.io.Writer
abstract class BaseDefaultsProvider(
val context: Context,
val settingsManager: SettingsManager
): SettingsProvider {
abstract val booleanDefaults: Map<String, Boolean>
abstract val intDefaults: Map<String, Int>
abstract val longDefaults: Map<String, Long>
abstract val stringDefaults: Map<String, String?>
override fun canWrite() = false
override fun close() {
// override this, if needed
}
override fun forceReload() {
// override this, if needed
}
override fun contains(key: String) =
booleanDefaults.containsKey(key) ||
intDefaults.containsKey(key) ||
longDefaults.containsKey(key) ||
stringDefaults.containsKey(key)
override fun getBoolean(key: String) = booleanDefaults[key]
override fun getInt(key: String) = intDefaults[key]
override fun getLong(key: String) = longDefaults[key]
override fun getString(key: String) = stringDefaults[key]
override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError()
override fun putInt(key: String, value: Int?) = throw NotImplementedError()
override fun putLong(key: String, value: Long?) = throw NotImplementedError()
override fun putString(key: String, value: String?) = throw NotImplementedError()
override fun remove(key: String) = throw NotImplementedError()
override fun dump(writer: Writer) {
val strValues = mutableMapOf<String, String?>()
strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(stringDefaults)
val table = TextTable("Setting", "Value")
for ((key, value) in strValues.toSortedMap())
table.addLine(key, value)
writer.write(table.toString())
}
}

View File

@@ -1,55 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.content.Context
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import javax.inject.Inject
class DefaultsProvider(
context: Context,
settingsManager: SettingsManager
): BaseDefaultsProvider(context, settingsManager) {
override val booleanDefaults = mutableMapOf(
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false),
Pair(Settings.IGNORE_VPN_NETWORK_CAPABILITY, true)
)
override val intDefaults = mapOf(
Pair(Settings.PRESELECT_COLLECTIONS, Settings.PRESELECT_COLLECTIONS_NONE),
Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM),
Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS
)
override val longDefaults = mapOf<String, Long>(
Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */
)
override val stringDefaults = mapOf(
Pair(Settings.PROXY_HOST, "localhost"),
Pair(Settings.PRESELECT_COLLECTIONS_EXCLUDED, "/z-app-generated--contactsinteraction--recent/") // Nextcloud "Recently Contacted" address book
)
class Factory @Inject constructor(): SettingsProviderFactory {
override fun getProviders(context: Context, settingsManager: SettingsManager) =
listOf(DefaultsProvider(context, settingsManager))
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DefaultsProviderFactoryModule {
@Binds
@IntoMap @IntKey(/* priority */ 0)
abstract fun factory(impl: Factory): SettingsProviderFactory
}
}

View File

@@ -1,13 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.content.Context
interface SettingsProviderFactory {
fun getProviders(context: Context, settingsManager: SettingsManager): Iterable<SettingsProvider>
}

View File

@@ -1,176 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.setAndVerifyUserData
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
/**
* Sync logic for address books
*/
class AddressBookSyncer(
context: Context
) : Syncer(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AddressBooksSyncerEntryPoint {
fun contactsSyncManagerFactory(): ContactsSyncManager.Factory
fun settingsManager(): SettingsManager
}
companion object {
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
private val entryPoint = EntryPointAccessors.fromApplication<AddressBooksSyncerEntryPoint>(context)
val settingsManager = entryPoint.settingsManager()
override fun sync(
account: Account,
extras: Array<String>,
authority: String, // address book authority (not contacts authority)
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient, // for noop address book provider (not for contacts provider)
syncResult: SyncResult
) {
if (updateLocalAddressBooks(account, syncResult)) {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { contactsProvider ->
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
Logger.log.info("Synchronizing address book $addressBookAccount")
syncAddresBook(
addressBookAccount,
extras,
ContactsContract.AUTHORITY,
httpClient,
contactsProvider,
syncResult
)
}
}
}
}
private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getByServiceAndSync(service.id))
remoteAddressBooks[collection.url] = collection
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
if (remoteAddressBooks.isEmpty())
Logger.log.info("No contacts permission, but no address book selected for synchronization")
else
Logger.log.warning("No contacts permission, but address books are selected for synchronization")
return false
}
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY).use { contactsProvider ->
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return false
}
val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = addressBook.url.toHttpUrl()
val info = remoteAddressBooks[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info, forceAllReadOnly)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remoteAddressBooks -= url
}
}
// create new local address books
for ((_, info) in remoteAddressBooks) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, contactsProvider, account, info, forceAllReadOnly)
}
}
return true
}
fun syncAddresBook(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
val accountSettings = AccountSettings(context, account)
val addressBook = LocalAddressBook(context, account, provider)
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
if (previousGroupMethod != groupMethod) {
Logger.log.info("Group method changed, deleting all local contacts/groups")
// delete all local contacts and groups so that they will be downloaded again
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
// reset sync state
addressBook.syncState = null
}
}
accountSettings.accountManager.setAndVerifyUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
Logger.log.info("Synchronizing address book: ${addressBook.url}")
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
val syncManagerFactory = entryPoint.contactsSyncManagerFactory()
val syncManager = syncManagerFactory.contactsSyncManager(account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook)
syncManager.performSync()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
}
Logger.log.info("Contacts sync complete")
}
}

View File

@@ -1,268 +0,0 @@
/*
* 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 at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalendarData
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.util.DateUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.property.Action
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.io.FileUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.time.Duration
import java.time.ZonedDateTime
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles events (VEVENT)
*/
class CalendarSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted accountSettings: AccountSettings,
@Assisted extras: Array<String>,
@Assisted httpClient: HttpClient,
@Assisted authority: String,
@Assisted syncResult: SyncResult,
@Assisted localCalendar: LocalCalendar,
@ApplicationContext context: Context,
db: AppDatabase
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(account, accountSettings, httpClient, extras, authority, syncResult, localCalendar, context, db) {
@AssistedFactory
interface Factory {
fun calendarSyncManager(
account: Account,
accountSettings: AccountSettings,
extras: Array<String>,
httpClient: HttpClient,
authority: String,
syncResult: SyncResult,
localCalendar: LocalCalendar
): CalendarSyncManager
}
override fun prepare(): Boolean {
collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
// if there are dirty exceptions for events, mark their master events as dirty, too
localCollection.processDirtyExceptions()
// now find dirty events that have no instances and set them to deleted
localCollection.deleteDirtyEventsWithoutInstances()
return true
}
override fun queryCapabilities(): SyncState? =
remoteExceptionContext {
var syncState: SyncState? = null
it.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
Logger.log.info("Calendar accepts events up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
}
Logger.log.info("Calendar supports Collection Sync: $hasCollectionSync")
syncState
}
override fun syncAlgorithm() =
if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
SyncAlgorithm.PROPFIND_REPORT
else
SyncAlgorithm.COLLECTION_SYNC
override fun processLocallyDeleted(): Boolean {
if (localCollection.readOnly) {
var modified = false
for (event in localCollection.findDeleted()) {
Logger.log.warning("Restoring locally deleted event (read-only calendar!)")
localExceptionContext(event) { it.resetDeleted() }
modified = true
}
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
// it's not enough to force synchronization (by returning true),
// but we also need to make sure all events are downloaded again.
if (modified)
localCollection.lastSyncState = null
return modified
}
// mirror deletions to remote collection (DELETE)
return super.processLocallyDeleted()
}
override fun uploadDirty(): Boolean {
var modified = false
if (localCollection.readOnly) {
for (event in localCollection.findDirty()) {
Logger.log.warning("Resetting locally modified event to ETag=null (read-only calendar!)")
localExceptionContext(event) { it.clearDirty(null, null) }
modified = true
}
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
// it's not enough to force synchronization (by returning true),
// but we also need to make sure all events are downloaded again.
if (modified)
localCollection.lastSyncState = null
}
// generate UID/file name for newly created events
val superModified = super.uploadDirty()
// return true when any operation returned true
return modified or superModified
}
override fun generateUpload(resource: LocalEvent): RequestBody = localExceptionContext(resource) {
val event = requireNotNull(resource.event)
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
val os = ByteArrayOutputStream()
event.write(os)
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
}
override fun listAllRemote(callback: MultiResponseCallback) {
// calculate time range limits
val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays ->
ZonedDateTime.now().minusDays(pastDays.toLong()).toInstant()
}
return remoteExceptionContext { remote ->
Logger.log.info("Querying events since $limitStart")
remote.calendarQuery(Component.VEVENT, limitStart, null, callback)
}
}
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
remoteExceptionContext {
it.multiget(bunch) { response, _ ->
responseExceptionContext(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@responseExceptionContext
}
val eTag = response[GetETag::class.java]?.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,
scheduleTag,
StringReader(iCal)
)
}
}
}
}
override fun postProcess() {}
// helpers
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
val events: List<Event>
try {
events = Event.eventsFromReader(reader)
} catch (e: InvalidCalendarException) {
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
if (events.size == 1) {
val event = events.first()
// set default reminder for non-full-day events, if requested
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
// Needed for calendars to actually show a notification.
properties += Action.DISPLAY
}
Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
event.alarms += alarm
}
// update local event, if it exists
localExceptionContext(localCollection.findByName(fileName)) { local ->
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event)
local.eTag = eTag
local.scheduleTag = scheduleTag
local.update(event)
syncResult.stats.numUpdates++
} else {
Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event)
localExceptionContext(LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.add()
}
syncResult.stats.numInserts++
}
}
} else
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_event)
}

View File

@@ -1,102 +0,0 @@
/*
* 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 android.content.Context
import android.content.SyncResult
import android.provider.CalendarContract
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidCalendar
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
/**
* Sync logic for calendars
*/
class CalendarSyncer(context: Context): Syncer(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CalendarSyncerEntryPoint {
fun calendarSyncManagerFactory(): CalendarSyncManager.Factory
}
private val entryPoint = EntryPointAccessors.fromApplication<CalendarSyncerEntryPoint>(context)
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
val accountSettings = AccountSettings(context, account)
if (accountSettings.getEventColors())
AndroidCalendar.insertColors(provider, account)
else
AndroidCalendar.removeColors(provider, account)
updateLocalCalendars(provider, account, accountSettings)
val calendars = AndroidCalendar
.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
for (calendar in calendars) {
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
val syncManagerFactory = entryPoint.calendarSyncManagerFactory()
val syncManager = syncManagerFactory.calendarSyncManager(account, accountSettings, extras, httpClient.value, authority, syncResult, calendar)
syncManager.performSync()
}
}
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
remoteCalendars[collection.url] = collection
}
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let {
val url = it.toHttpUrl()
val info = remoteCalendars[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remoteCalendars -= url
}
}
// create new local calendars
for ((_, info) in remoteCalendars) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
}
}

View File

@@ -1,461 +0,0 @@
/*
* 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 android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.dav4jvm.DavAddressBook
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.carddav.AddressData
import at.bitfire.dav4jvm.property.carddav.MaxResourceSize
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
import at.bitfire.dav4jvm.property.webdav.GetContentType
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddress
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalGroup
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.groups.CategoriesStrategy
import at.bitfire.davdroid.sync.groups.VCard4Strategy
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.sameTypeAs
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import ezvcard.VCardVersion
import ezvcard.io.CannotParseException
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.io.FileUtils
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.Reader
import java.io.StringReader
import java.util.logging.Level
/**
* Synchronization manager for CardDAV collections; handles contacts and groups.
*
* Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:
*
* 1. CATEGORIES: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in [uploadDirty]. Empty groups can be deleted without further processing,
* which is done in [postProcess] because groups may become empty after downloading
* updated remote contacts.
*
* 2. Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
*
* However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in [uploadDirty].
*
* When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* [LocalGroup.COLUMN_PENDING_MEMBERS]. In [postProcess],
* these "pending memberships" are assigned to the actual contacts and then cleaned up.
*/
class ContactsSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted accountSettings: AccountSettings,
@Assisted httpClient: HttpClient,
@Assisted extras: Array<String>,
@Assisted authority: String,
@Assisted syncResult: SyncResult,
@Assisted val provider: ContentProviderClient,
@Assisted localAddressBook: LocalAddressBook,
@ApplicationContext context: Context,
db: AppDatabase
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
account, accountSettings, httpClient, extras, authority, syncResult, localAddressBook,
context, db
) {
@AssistedFactory
interface Factory {
fun contactsSyncManager(
account: Account,
accountSettings: AccountSettings,
httpClient: HttpClient,
extras: Array<String>,
authority: String,
syncResult: SyncResult,
provider: ContentProviderClient,
localAddressBook: LocalAddressBook
): ContactsSyncManager
}
companion object {
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
private var hasVCard4 = false
private var hasJCard = false
private val groupStrategy = when (accountSettings.getGroupMethod()) {
GroupMethod.GROUP_VCARDS -> VCard4Strategy(localAddressBook)
GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook)
}
/**
* Used to download images which are referenced by URL
*/
private lateinit var resourceDownloader: ResourceDownloader
override fun prepare(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localCollection.verifyDirty()
val deleted = localCollection.findDeleted().size
if (extras.contains(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
return false
}
}
collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
resourceDownloader = ResourceDownloader(davCollection.location)
Logger.log.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
return true
}
override fun queryCapabilities(): SyncState? {
return remoteExceptionContext {
var syncState: SyncState? = null
it.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
Logger.log.info("Address book accepts vCards up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
}
response[SupportedAddressData::class.java]?.let { supported ->
hasVCard4 = supported.hasVCard4()
// temporarily disable jCard because of https://github.com/nextcloud/server/issues/29693
// hasJCard = supported.hasJCard()
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
}
// Logger.log.info("Server supports jCard: $hasJCard")
Logger.log.info("Address book supports vCard4: $hasVCard4")
Logger.log.info("Address book supports Collection Sync: $hasCollectionSync")
syncState
}
}
override fun syncAlgorithm() =
if (hasCollectionSync)
SyncAlgorithm.COLLECTION_SYNC
else
SyncAlgorithm.PROPFIND_REPORT
override fun processLocallyDeleted() =
if (localCollection.readOnly) {
var modified = false
for (group in localCollection.findDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
localExceptionContext(group) { it.resetDeleted() }
modified = true
}
for (contact in localCollection.findDeletedContacts()) {
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
localExceptionContext(contact) { it.resetDeleted() }
modified = true
}
/* This is unfortunately dirty: When a contact has been inserted to a read-only address book
that supports Collection Sync, it's not enough to force synchronization (by returning true),
but we also need to make sure all contacts are downloaded again. */
if (modified)
localCollection.lastSyncState = null
modified
} else
// mirror deletions to remote collection (DELETE)
super.processLocallyDeleted()
override fun uploadDirty(): Boolean {
var modified = false
if (localCollection.readOnly) {
for (group in localCollection.findDirtyGroups()) {
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
localExceptionContext(group) { it.clearDirty(null, null) }
modified = true
}
for (contact in localCollection.findDirtyContacts()) {
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
localExceptionContext(contact) { it.clearDirty(null, null) }
modified = true
}
// see same position in processLocallyDeleted
if (modified)
localCollection.lastSyncState = null
} else
// we only need to handle changes in groups when the address book is read/write
groupStrategy.beforeUploadDirty()
// generate UID/file name for newly created contacts
val superModified = super.uploadDirty()
// return true when any operation returned true
return modified or superModified
}
override fun generateUpload(resource: LocalAddress): RequestBody =
localExceptionContext(resource) {
val contact: Contact = when (resource) {
is LocalContact -> resource.getContact()
is LocalGroup -> resource.getContact()
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
}
Logger.log.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact)
val os = ByteArrayOutputStream()
val mimeType: MediaType
when {
hasJCard -> {
mimeType = DavAddressBook.MIME_JCARD
contact.writeJCard(os)
}
hasVCard4 -> {
mimeType = DavAddressBook.MIME_VCARD4
contact.writeVCard(VCardVersion.V4_0, os)
}
else -> {
mimeType = DavAddressBook.MIME_VCARD3_UTF8
contact.writeVCard(VCardVersion.V3_0, os)
}
}
return@localExceptionContext(os.toByteArray().toRequestBody(mimeType))
}
override fun listAllRemote(callback: MultiResponseCallback) =
remoteExceptionContext {
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
}
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch")
remoteExceptionContext {
val contentType: String?
val version: String?
when {
hasJCard -> {
contentType = DavUtils.MEDIA_TYPE_JCARD.toString()
version = VCardVersion.V4_0.version
}
hasVCard4 -> {
contentType = DavUtils.MEDIA_TYPE_VCARD.toString()
version = VCardVersion.V4_0.version
}
else -> {
contentType = DavUtils.MEDIA_TYPE_VCARD.toString()
version = null // 3.0 is the default version; don't request 3.0 explicitly because maybe some vCard3-only servers don't understand it
}
}
it.multiget(bunch, contentType, version) { response, _ ->
responseExceptionContext(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@responseExceptionContext
}
val eTag = response[GetETag::class.java]?.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,
StringReader(card),
isJCard,
resourceDownloader
)
}
}
}
}
override fun postProcess() {
groupStrategy.postProcess()
}
// helpers
private fun processCard(fileName: String, eTag: String, reader: Reader, jCard: Boolean, downloader: Contact.Downloader) {
Logger.log.info("Processing CardDAV resource $fileName")
val contacts = try {
Contact.fromReader(reader, jCard, downloader)
} catch (e: CannotParseException) {
Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
if (contacts.isEmpty()) {
Logger.log.warning("Received vCard without data, ignoring")
return
} else if (contacts.size > 1)
Logger.log.warning("Received multiple vCards, using first one")
val newData = contacts.first()
groupStrategy.verifyContactBeforeSaving(newData)
// update local contact, if it exists
localExceptionContext(localCollection.findByName(fileName)) {
var local = it
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
if (local is LocalGroup && newData.group) {
// update group
local.eTag = eTag
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
local.eTag = eTag
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
} else {
// group has become an individual contact or vice versa, delete and create with new type
local.delete()
local = null
}
}
if (local == null) {
if (newData.group) {
Logger.log.log(Level.INFO, "Creating local group", newData)
localExceptionContext(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
group.add()
local = group
}
} else {
Logger.log.log(Level.INFO, "Creating local contact", newData)
localExceptionContext(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
contact.add()
local = contact
}
}
syncResult.stats.numInserts++
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
(local as? LocalContact)?.updateHashCode(null)
}
}
// downloader helper class
private inner class ResourceDownloader(
val baseUrl: HttpUrl
): Contact.Downloader {
override fun download(url: String, accepts: String): ByteArray? {
val httpUrl = url.toHttpUrlOrNull()
if (httpUrl == null) {
Logger.log.log(Level.SEVERE, "Invalid external resource URL", url)
return null
}
// authenticate only against a certain host, and only upon request
val client = HttpClient.Builder(context, baseUrl.host, accountSettings.credentials())
.followRedirects(true) // allow redirects
.build()
try {
val response = client.okHttpClient.newCall(Request.Builder()
.get()
.url(httpUrl)
.build()).execute()
if (response.isSuccessful)
return response.body?.bytes()
else
Logger.log.warning("Couldn't download external resource")
} catch(e: IOException) {
Logger.log.log(Level.SEVERE, "Couldn't download external resource", e)
} finally {
client.close()
}
return null
}
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_contact)
}

View File

@@ -1,199 +0,0 @@
/*
* 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 at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalendarData
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.resource.LocalJtxICalObject
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.JtxICalObject
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.io.FileUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.logging.Level
class JtxSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted accountSettings: AccountSettings,
@Assisted extras: Array<String>,
@Assisted httpClient: HttpClient,
@Assisted authority: String,
@Assisted syncResult: SyncResult,
@Assisted localCollection: LocalJtxCollection,
@ApplicationContext context: Context,
db: AppDatabase
): SyncManager<LocalJtxICalObject, LocalJtxCollection, DavCalendar>(account, accountSettings, httpClient, extras, authority, syncResult, localCollection, context, db) {
@AssistedFactory
interface Factory {
fun jtxSyncManager(
account: Account,
accountSettings: AccountSettings,
extras: Array<String>,
httpClient: HttpClient,
authority: String,
syncResult: SyncResult,
localCollection: LocalJtxCollection
): JtxSyncManager
}
override fun prepare(): Boolean {
collectionURL = (localCollection.url ?: return false).toHttpUrlOrNull() ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
return true
}
override fun queryCapabilities() =
remoteExceptionContext {
var syncState: SyncState? = null
it.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
Logger.log.info("Collection accepts resources up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
}
syncState = syncState(response)
}
}
syncState
}
override fun generateUpload(resource: LocalJtxICalObject): RequestBody = localExceptionContext(resource) {
Logger.log.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource)
val os = ByteArrayOutputStream()
resource.write(os)
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
override fun listAllRemote(callback: MultiResponseCallback) {
remoteExceptionContext { remote ->
if (localCollection.supportsVTODO) {
Logger.log.info("Querying tasks")
remote.calendarQuery("VTODO", null, null, callback)
}
if (localCollection.supportsVJOURNAL) {
Logger.log.info("Querying journals")
remote.calendarQuery("VJOURNAL", null, null, callback)
}
}
}
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
// multiple iCalendars, use calendar-multi-get
remoteExceptionContext {
it.multiget(bunch) { response, _ ->
responseExceptionContext(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@responseExceptionContext
}
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))
}
}
}
}
override fun postProcess() {
localCollection.updateLastSync()
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_event)
private fun processICalObject(fileName: String, eTag: String, reader: Reader) {
val icalobjects: MutableList<JtxICalObject> = mutableListOf()
try {
// parse the reader content and return the list of ICalObjects
icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection))
} catch (e: InvalidCalendarException) {
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
Logger.log.log(Level.INFO, "Found ${icalobjects.size} entries in $fileName", icalobjects)
icalobjects.forEach { jtxICalObject ->
// if the entry is a recurring entry (and therefore has a recurid)
// we udpate the existing (generated) entry
if(jtxICalObject.recurid != null) {
localExceptionContext(localCollection.findRecurring(jtxICalObject.uid, jtxICalObject.recurid!!, jtxICalObject.dtstart!!)) { local ->
Logger.log.log(Level.INFO, "Updating $fileName with recur instance ${jtxICalObject.recurid} in local list", jtxICalObject)
if(local != null) {
local.update(jtxICalObject)
syncResult.stats.numUpdates++
} else {
localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.applyNewData(jtxICalObject)
it.add()
}
syncResult.stats.numInserts++
}
}
} else {
// otherwise we insert or update the main entry
localExceptionContext(localCollection.findByName(fileName)) { local ->
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local list", jtxICalObject)
local.eTag = eTag
local.update(jtxICalObject)
syncResult.stats.numUpdates++
} else {
Logger.log.log(Level.INFO, "Adding $fileName to local list", jtxICalObject)
localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.applyNewData(jtxICalObject)
it.add()
}
syncResult.stats.numInserts++
}
}
}
}
}
}

View File

@@ -1,120 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
/**
* Sync logic for jtx board
*/
class JtxSyncer(context: Context): Syncer(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface JtxSyncerEntryPoint {
fun jtxSyncManagerFactory(): JtxSyncManager.Factory
}
private val entryPoint = EntryPointAccessors.fromApplication<JtxSyncerEntryPoint>(context)
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
// check whether jtx Board is new enough
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
// make sure account can be seen by task provider
if (Build.VERSION.SDK_INT >= 26) {
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
and starts them again! So make sure setAccountVisibility is only called when necessary. */
val am = AccountManager.get(context)
if (am.getAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName) != AccountManager.VISIBILITY_VISIBLE)
am.setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE)
}
val accountSettings = AccountSettings(context, account)
// sync list of collections
updateLocalCollections(account, provider, accountSettings)
// sync contents of collections
val collections = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
for (collection in collections) {
Logger.log.info("Synchronizing $collection")
val syncManagerFactory = entryPoint.jtxSyncManagerFactory()
val syncManager = syncManagerFactory.jtxSyncManager(account, accountSettings, extras, httpClient.value, authority, syncResult, collection)
syncManager.performSync()
}
} catch (e: TaskProvider.ProviderTooOldException) {
TaskUtils.notifyProviderTooOld(context, e)
}
}
private fun updateLocalCollections(account: Account, client: ContentProviderClient, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteCollections = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncJtxCollections(service.id))
remoteCollections[collection.url] = collection
val updateColors = settings.getManageCalendarColors()
for (jtxCollection in JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null))
jtxCollection.url?.let { strUrl ->
val url = strUrl.toHttpUrl()
val info = remoteCollections[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local collection $url")
jtxCollection.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local collection $url", info)
val owner = info.ownerId?.let { db.principalDao().get(it) }
jtxCollection.updateCollection(info, owner, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteCollections -= url
}
}
// create new local collections
for ((_,info) in remoteCollections) {
Logger.log.log(Level.INFO, "Adding local collections", info)
val owner = info.ownerId?.let { db.principalDao().get(it) }
LocalJtxCollection.create(account, client, info, owner)
}
}
}

View File

@@ -1,68 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Scope
import javax.inject.Singleton
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class SyncScoped
/**
* Custom Hilt component for running syncs, lifetime managed by [SyncComponentManager].
* Dependencies installed in this component and scoped with [SyncScoped] (like SyncValidators)
* will have a lifetime of all active syncs.
*/
@SyncScoped
@DefineComponent(parent = SingletonComponent::class)
interface SyncComponent
@DefineComponent.Builder
interface SyncComponentBuilder {
fun build(): SyncComponent
}
/**
* Manages the lifecycle of [SyncComponent] by using [WeakReference].
*/
@Singleton
class SyncComponentManager @Inject constructor(
val provider: Provider<SyncComponentBuilder>
) {
private var componentRef: WeakReference<SyncComponent>? = null
/**
* Returns a [SyncComponent]. When there is already a known [SyncComponent],
* it will be used. Otherwise, a new one will be created and returned.
*
* It is then stored using a [WeakReference] so as long as the component
* stays in memory, it will always be returned. When it's not used anymore
* by anyone, it can be removed by garbage collection. After this, it will be
* created again when [get] is called.
*
* @return singleton [SyncComponent] (will be garbage collected when not referenced anymore)
*/
@Synchronized
fun get(): SyncComponent {
val component = componentRef?.get()
// check for cached component
if (component != null)
return component
// cached component not available, build new one
val newComponent = provider.get().build()
componentRef = WeakReference(newComponent)
return newComponent
}
}

View File

@@ -1,46 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.app.Application
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadFactory
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Creates a [CoroutineDispatcher] with multiple threads that guarantees that the threads
* have set their contextClassLoader to the application context's class loader.
*
* We use our own dispatcher to
*
* - make sure that all threads have [Thread.getContextClassLoader] set, which is required for ical4j (because it uses [ServiceLoader]),
* - control the global number of sync threads.
*/
@Singleton
class SyncDispatcher @Inject constructor(
context: Application
) {
val dispatcher = createDispatcher(context.classLoader)
private fun createDispatcher(classLoader: ClassLoader): CoroutineDispatcher =
ThreadPoolExecutor(
0, Runtime.getRuntime().availableProcessors(),
10, TimeUnit.SECONDS, LinkedBlockingQueue(),
object: ThreadFactory {
val group = ThreadGroup("sync-work")
override fun newThread(r: Runnable) =
Thread(group, r).apply {
contextClassLoader = classLoader
}
}
).asCoroutineDispatcher()
}

View File

@@ -1,963 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
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
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Error
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.ConflictException
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.ForbiddenException
import at.bitfire.dav4jvm.exception.GoneException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.exception.PreconditionFailedException
import at.bitfire.dav4jvm.exception.ServiceUnavailableException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.db.SyncStats
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.UsesThreadContextClassLoader
import at.bitfire.vcard4android.ContactsStorageException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.lang3.exception.ContextedException
import org.dmfs.tasks.contract.TaskContract
import java.io.IOException
import java.io.InterruptedIOException
import java.net.HttpURLConnection
import java.security.cert.CertificateException
import java.time.Instant
import java.util.LinkedList
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import java.util.logging.Level
import javax.net.ssl.SSLHandshakeException
@UsesThreadContextClassLoader
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
val account: Account,
val accountSettings: AccountSettings,
val httpClient: HttpClient,
val extras: Array<String>,
val authority: String,
val syncResult: SyncResult,
val localCollection: CollectionType,
// injected
val context: Context,
val db: AppDatabase
) {
enum class SyncAlgorithm {
PROPFIND_REPORT,
COLLECTION_SYNC
}
companion object {
/** Maximum number of resources that are requested with one multiget request. */
const val MAX_MULTIGET_RESOURCES = 10
const val DELAY_UNTIL_DEFAULT = 15*60L // 15 min
const val DELAY_UNTIL_MIN = 1*60L // 1 min
const val DELAY_UNTIL_MAX = 2*60*60L // 2 hours
/**
* Context label for [org.apache.commons.lang3.exception.ContextedException].
* Context value is the [at.bitfire.davdroid.resource.LocalResource]
* which is related to the exception cause.
*/
const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource"
/**
* Context label for [org.apache.commons.lang3.exception.ContextedException].
* Context value is the [okhttp3.HttpUrl] of the remote resource
* which is related to the exception cause.
*/
const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource"
/**
* Returns appropriate sync retry delay in seconds, considering the servers suggestion
* ([DELAY_UNTIL_DEFAULT] if no server suggestion).
*
* Takes current time into account to calculate intervals. Interval
* will be restricted to values between [DELAY_UNTIL_MIN] and [DELAY_UNTIL_MAX].
*
* @param retryAfter optional server suggestion on how long to wait before retrying
* @return until when to wait before sync can be retried
*/
fun getDelayUntil(retryAfter: Instant?): Instant {
val now = Instant.now()
if (retryAfter == null)
return now.plusSeconds(DELAY_UNTIL_DEFAULT)
// take server suggestion, but restricted to plausible min/max values
val min = now.plusSeconds(DELAY_UNTIL_MIN)
val max = now.plusSeconds(DELAY_UNTIL_MAX)
return when {
min > retryAfter -> min
max < retryAfter -> max
else -> retryAfter
}
}
}
init {
// required for ServiceLoader -> ical4j -> ical4android
Ical4Android.checkThreadContextClassLoader()
}
protected val mainAccount = if (localCollection is LocalAddressBook)
localCollection.requireMainAccount()
else
account
protected val notificationManager = NotificationManagerCompat.from(context)
protected val notificationTag = localCollection.tag
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: RemoteType
protected var hasCollectionSync = false
fun performSync() {
// dismiss previous error notifications
notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
unwrapExceptions({
Logger.log.info("Preparing synchronization")
if (!prepare()) {
Logger.log.info("No reason to synchronize, aborting")
return@unwrapExceptions
}
// log sync time
saveSyncTime()
Logger.log.info("Querying server capabilities")
var remoteSyncState = queryCapabilities()
Logger.log.info("Processing local deletes/updates")
val modificationsPresent = processLocallyDeleted() or uploadDirty() // bitwise OR guarantees that both expressions are evaluated
if (extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC)) {
Logger.log.info("Forcing re-synchronization of all entries")
// forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC)
localCollection.lastSyncState = null
remoteSyncState = null
// forget sync state of members (→ download all members again and update them locally)
localCollection.forgetETags()
}
if (modificationsPresent || syncRequired(remoteSyncState))
when (syncAlgorithm()) {
SyncAlgorithm.PROPFIND_REPORT -> {
Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
resetPresentRemotely()
// get current sync state
if (modificationsPresent)
remoteSyncState = querySyncState()
// list and process all entries at current sync state (which may be the same as or newer than remoteSyncState)
Logger.log.info("Processing remote entries")
syncRemote { callback ->
listAllRemote(callback)
}
Logger.log.info("Deleting entries which are not present remotely anymore")
deleteNotPresentRemotely()
Logger.log.info("Post-processing")
postProcess()
Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
localCollection.lastSyncState = remoteSyncState
}
SyncAlgorithm.COLLECTION_SYNC -> {
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
var initialSync = false
if (syncState == null) {
Logger.log.info("Starting initial sync")
initialSync = true
resetPresentRemotely()
} else if (syncState.initialSync == true) {
Logger.log.info("Continuing initial sync")
initialSync = true
}
var furtherChanges = false
do {
Logger.log.info("Listing changes since $syncState")
syncRemote { callback ->
try {
val result = listRemoteChanges(syncState, callback)
syncState = SyncState.fromSyncToken(result.first, initialSync)
furtherChanges = result.second
} catch(e: HttpException) {
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
Logger.log.info("Sync token invalid, performing initial sync")
initialSync = true
resetPresentRemotely()
val result = listRemoteChanges(null, callback)
syncState = SyncState.fromSyncToken(result.first, initialSync)
furtherChanges = result.second
} else
throw e
}
}
Logger.log.log(Level.INFO, "Saving sync state", syncState)
localCollection.lastSyncState = syncState
Logger.log.info("Server has further changes: $furtherChanges")
} while(furtherChanges)
if (initialSync) {
// initial sync is finished, remove all local resources which have not been listed by server
Logger.log.info("Deleting local resources which are not on server (anymore)")
deleteNotPresentRemotely()
// remove initial sync flag
syncState!!.initialSync = false
Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
localCollection.lastSyncState = syncState
}
Logger.log.info("Post-processing")
postProcess()
}
}
else
Logger.log.info("Remote collection didn't change, no reason to sync")
}, { e, local, remote ->
when (e) {
// DeadObjectException (may occur when syncing takes too long and process is demoted to cached):
// re-throw to base Syncer → will cause soft error and restart the sync process
is DeadObjectException ->
throw e
// sync was cancelled or account has been removed: re-throw to BaseSyncer
is InterruptedException,
is InterruptedIOException,
is InvalidAccountException ->
throw e
// specific I/O errors
is SSLHandshakeException -> {
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
// when a certificate is rejected by cert4android, the cause will be a CertificateException
if (e.cause !is CertificateException)
notifyException(e, local, remote)
}
// specific HTTP errors
is ServiceUnavailableException -> {
Logger.log.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
}
// all others
else ->
notifyException(e, local, remote)
}
})
}
/**
* Saves the sync time of the synced account and service.
*/
private fun saveSyncTime() {
val serviceType = when (authority) {
ContactsContract.AUTHORITY,
context.getString(R.string.address_books_authority) -> // Contacts and Address books
Service.TYPE_CARDDAV
else -> // Calendars + other (ie. tasks
Service.TYPE_CALDAV
}
val accountName =
if (localCollection is LocalAddressBook)
localCollection.requireMainAccount().name
else
account.name
db.runInTransaction {
val service = db.serviceDao().getByAccountAndType(accountName, serviceType)
?: return@runInTransaction
val collection = db.collectionDao().getByServiceAndUrl(service.id, collectionURL.toString())
?: return@runInTransaction
db.syncStatsDao().insertOrReplace(
SyncStats(0, collection.id, authority, System.currentTimeMillis())
)
}
}
/**
* Prepares synchronization. Sets the lateinit properties [collectionURL] and [davCollection].
*
* @return whether synchronization shall be performed
*/
protected abstract fun prepare(): Boolean
/**
* Queries the server for synchronization capabilities like specific report types,
* data formats etc.
*
* Should also query and save the initial sync state (e.g. CTag/sync-token).
*
* @return current sync state
*/
protected abstract fun queryCapabilities(): SyncState?
/**
* Processes locally deleted entries. This can mean:
*
* - forwarding them to the server (HTTP `DELETE`)
* - resetting their local state so that they will be downloaded again because they're read-only
*
* @return whether local resources have been processed so that a synchronization is always necessary
*/
protected open fun processLocallyDeleted(): Boolean {
var numDeleted = 0
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
val localList = localCollection.findDeleted()
for (local in localList) {
localExceptionContext(local) {
val fileName = local.fileName
if (fileName != null) {
val lastScheduleTag = local.scheduleTag
val lastETag = if (lastScheduleTag == null) local.eTag else null
Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)")
remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
try {
remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {}
numDeleted++
} catch (e: HttpException) {
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
}
}
} else
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
syncResult.stats.numDeletes++
}
}
Logger.log.info("Removed $numDeleted record(s) from server")
return numDeleted > 0
}
/**
* Processes locally modified resources to the server. This can mean:
*
* - uploading them to the server (HTTP `PUT`)
* - resetting their local state so that they will be downloaded again because they're read-only
*
* @return whether local resources have been processed so that a synchronization is always necessary
*/
protected open fun uploadDirty(): Boolean {
var numUploaded = 0
// upload dirty resources (parallelized)
runBlocking {
for (local in localCollection.findDirty())
launch {
localExceptionContext(local) {
uploadDirty(local)
numUploaded++
}
}
}
syncResult.stats.numEntries += numUploaded
Logger.log.info("Sent $numUploaded record(s) to server")
return numUploaded > 0
}
protected fun uploadDirty(local: ResourceType) {
val existingFileName = local.fileName
var newFileName: String? = null
var eTag: String? = null
var scheduleTag: String? = null
val readTagsFromResponse: (okhttp3.Response) -> Unit = { response ->
eTag = GetETag.fromResponse(response)?.eTag
scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
}
try {
if (existingFileName == null) { // new resource
newFileName = local.prepareForUpload()
val uploadUrl = collectionURL.newBuilder().addPathSegment(newFileName).build()
remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote ->
Logger.log.info("Uploading new record ${local.id} -> $newFileName")
remote.put(generateUpload(local), ifNoneMatch = true, callback = readTagsFromResponse)
}
} else /* existingFileName != null */ { // updated resource
local.prepareForUpload()
val uploadUrl = collectionURL.newBuilder().addPathSegment(existingFileName).build()
remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote ->
val lastScheduleTag = local.scheduleTag
val lastETag = if (lastScheduleTag == null) local.eTag else null
Logger.log.info("Uploading modified record ${local.id} -> $existingFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)")
remote.put(generateUpload(local), ifETag = lastETag, ifScheduleTag = lastScheduleTag, callback = readTagsFromResponse)
}
}
} catch (e: ContextedException) {
when (val ex = e.cause) {
is ForbiddenException -> {
// HTTP 403 Forbidden
// If and only if the upload failed because of missing permissions, treat it like 412.
if (ex.errors.contains(Error.NEED_PRIVILEGES))
Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex)
else
throw e
}
is NotFoundException, is GoneException -> {
// HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore)
if (local.scheduleTag != null || local.eTag != null) { // this was an update of a previously existing resource
Logger.log.info("Original version of locally modified resource is not there (anymore), trying as fresh upload")
if (local.scheduleTag != null) // contacts don't support scheduleTag, don't try to set it without check
local.scheduleTag = null
local.eTag = null
uploadDirty(local) // if this fails with 404, too, the collection is gone
return
} else
throw e // the collection is probably gone
}
is ConflictException -> {
// HTTP 409 Conflict
// We can't interact with the user to resolve the conflict, so we treat 409 like 412.
Logger.log.info("Edit conflict, ignoring")
}
is PreconditionFailedException -> {
// HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile.
// Ignore this condition so that the resource can be downloaded and reset again.
Logger.log.info("Resource has been modified on the server before upload, ignoring")
}
else -> throw e
}
}
if (eTag != null)
Logger.log.fine("Received new ETag=$eTag after uploading")
else
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
local.clearDirty(newFileName, eTag, scheduleTag)
}
/**
* Generates the request body (iCalendar or vCard) from a local resource.
*
* @param resource local resource to generate the body from
*
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
*/
protected abstract fun generateUpload(resource: ResourceType): RequestBody
/**
* Determines whether a sync is required because there were changes on the server.
* For instance, this method can compare the collection's `CTag`/`sync-token` with
* the last known local value.
*
* When local changes have been uploaded ([processLocallyDeleted] and/or
* [uploadDirty] were true), a sync is always required and this method
* should *not* be evaluated.
*
* Will return _true_ if [Syncer.SYNC_EXTRAS_RESYNC] and/or
* [Syncer.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
*
* @param state remote sync state to compare local sync state with
*
* @return whether data has been changed on the server, i.e. whether running the
* sync algorithm is required
*/
protected open fun syncRequired(state: SyncState?): Boolean {
if (extras.contains(Syncer.SYNC_EXTRAS_RESYNC) ||
extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC))
return true
val localState = localCollection.lastSyncState
Logger.log.info("Local sync state = $localState, remote sync state = $state")
return when (state?.type) {
SyncState.Type.SYNC_TOKEN -> {
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
lastKnownToken != state.value
}
SyncState.Type.CTAG -> {
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
lastKnownCTag != state.value
}
else -> true
}
}
/**
* Determines which sync algorithm to use.
* @return
* - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV
* PROPFIND or specific REPORT requests), then compare and synchronize
* - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578)
*/
protected abstract fun syncAlgorithm(): SyncAlgorithm
/**
* Marks all local resources which shall be taken into consideration for this
* sync as "synchronizing". Purpose of marking is that resources which have been marked
* and are not present remotely anymore can be deleted.
*
* Used together with [deleteNotPresentRemotely].
*/
protected open fun resetPresentRemotely() {
val number = localCollection.markNotDirty(0)
Logger.log.info("Number of local non-dirty entries: $number")
}
/**
* Calls a callback to list remote resources. All resources from the returned
* list are downloaded and processed.
*
* @param listRemote function to list remote resources (for instance, all since a certain sync-token)
*/
protected open fun syncRemote(listRemote: (MultiResponseCallback) -> Unit) {
// thread-safe sync stats
val nInserted = AtomicInteger()
val nUpdated = AtomicInteger()
val nDeleted = AtomicInteger()
val nSkipped = AtomicInteger()
runBlocking {
// download queue
val toDownload = LinkedBlockingQueue<HttpUrl>()
fun download(url: HttpUrl?) {
if (url != null)
toDownload += url
if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) {
while (toDownload.size > 0) {
val bunch = LinkedList<HttpUrl>()
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
launch {
downloadRemote(bunch)
}
}
}
}
coroutineScope { // structured concurrency: blocks until all inner coroutines are finished
listRemote { response, relation ->
// ignore non-members
if (relation != Response.HrefRelation.MEMBER)
return@listRemote
// ignore collections
if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true)
return@listRemote
val name = response.hrefName()
if (response.isSuccess()) {
Logger.log.fine("Found remote resource: $name")
launch {
localExceptionContext(localCollection.findByName(name)) { local ->
if (local == null) {
Logger.log.info("$name has been added remotely, queueing download")
download(response.href)
nInserted.incrementAndGet()
} else {
val localETag = local.eTag
val remoteETag = response[GetETag::class.java]?.eTag
?: throw DavException("Server didn't provide ETag")
if (localETag == remoteETag) {
Logger.log.info("$name has not been changed on server (ETag still $remoteETag)")
nSkipped.incrementAndGet()
} else {
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
download(response.href)
nUpdated.incrementAndGet()
}
// mark as remotely present, so that this resource won't be deleted at the end
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
}
}
}
} else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
// collection sync: resource has been deleted on remote server
launch {
localExceptionContext(localCollection.findByName(name)) { local ->
Logger.log.info("$name has been deleted on server, deleting locally")
local?.delete()
nDeleted.incrementAndGet()
}
}
}
}
}
// download remaining resources
download(null)
}
// update sync stats
with(syncResult.stats) {
numInserts += nInserted.get()
numUpdates += nUpdated.get()
numDeletes += nDeleted.get()
numSkippedEntries += nSkipped.get()
}
}
protected abstract fun listAllRemote(callback: MultiResponseCallback)
protected open fun listRemoteChanges(syncState: SyncState?, callback: MultiResponseCallback): Pair<SyncToken, Boolean> {
var furtherResults = false
val report = davCollection.reportChanges(
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
false, null,
GetETag.NAME) { response, relation ->
when (relation) {
Response.HrefRelation.SELF ->
furtherResults = response.status?.code == 507
Response.HrefRelation.MEMBER ->
callback.onResponse(response, relation)
else ->
Logger.log.fine("Unexpected sync-collection response: $response")
}
}
var syncToken: SyncToken? = null
report.filterIsInstance(SyncToken::class.java).firstOrNull()?.let {
syncToken = it
}
if (syncToken == null)
throw DavException("Received sync-collection response without sync-token")
return Pair(syncToken!!, furtherResults)
}
/**
* Downloads and processes resources, given as a list of URLs. Will be called with a list
* of changed/new remote resources.
*
* Implementations should not use GET to fetch single resources, but always multi-get, even
* for single resources for these reasons:
*
* 1. GET can only be used without HTTP compression, because it may change the ETag.
* multi-get sends the ETag in the XML body, so there's no problem with compression.
* 2. Some servers are wrongly configured to suppress the ETag header in the response.
* With multi-get, the ETag is in the XML body, so it won't be affected by that.
* 3. If there are two methods to download resources (GET and multi-get), both methods
* have to be implemented, tested and maintained. Given that multi-get is required
* in any case, it's better to have only one method.
* 4. For users, it's strange behavior when DAVx5 can download multiple remote changes,
* but not a single one (or vice versa). So only one method is more user-friendly.
* 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests.
*/
protected abstract fun downloadRemote(bunch: List<HttpUrl>)
/**
* Locally deletes entries which are
* 1. not dirty and
* 2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT].
*
* Used together with [resetPresentRemotely] when a full listing has been received from
* the server to locally delete resources which are not present remotely (anymore).
*/
protected open fun deleteNotPresentRemotely() {
val removed = localCollection.removeNotDirtyMarked(0)
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
syncResult.stats.numDeletes += removed
}
/**
* Post-processing of synchronized entries, for instance contact group membership operations.
*/
protected abstract fun postProcess()
// sync helpers
protected fun syncState(dav: Response) =
dav[SyncToken::class.java]?.token?.let {
SyncState(SyncState.Type.SYNC_TOKEN, it)
} ?:
dav[GetCTag::class.java]?.cTag?.let {
SyncState(SyncState.Type.CTAG, it)
}
private fun querySyncState(): SyncState? {
var state: SyncState? = null
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF)
state = syncState(response)
}
return state
}
// exception helpers
private fun notifyException(e: Throwable, local: ResourceType?, remote: HttpUrl?) {
val message: String
when (e) {
is IOException,
is InterruptedIOException -> {
Logger.log.log(Level.WARNING, "I/O error", e)
message = context.getString(R.string.sync_error_io, e.localizedMessage)
syncResult.stats.numIoExceptions++
}
is UnauthorizedException -> {
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
message = context.getString(R.string.sync_error_authentication_failed)
syncResult.stats.numAuthExceptions++
}
is HttpException, is DavException -> {
Logger.log.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
}
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
syncResult.databaseError = true
}
else -> {
Logger.log.log(Level.SEVERE, "Unclassified sync error", e)
message = e.localizedMessage ?: e::class.java.simpleName
syncResult.stats.numParseExceptions++
}
}
val contentIntent: Intent
var viewItemAction: NotificationCompat.Action? = null
if (e is UnauthorizedException) {
contentIntent = Intent(context, AccountSettingsActivity::class.java)
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT,
if (authority == ContactsContract.AUTHORITY)
mainAccount
else
account)
} else {
contentIntent = buildDebugInfoIntent(e, local, remote)
if (local != null)
viewItemAction = buildViewItemAction(local)
}
// to make the PendingIntent unique
contentIntent.data = Uri.parse("davdroid:exception/${e.hashCode()}")
val channel: String
val priority: Int
if (e is IOException) {
channel = NotificationUtils.CHANNEL_SYNC_IO_ERRORS
priority = NotificationCompat.PRIORITY_MIN
} else {
channel = NotificationUtils.CHANNEL_SYNC_ERRORS
priority = NotificationCompat.PRIORITY_DEFAULT
}
val builder = NotificationUtils.newBuilder(context, channel)
builder .setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(localCollection.title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
.setSubText(mainAccount.name)
.setOnlyAlertOnce(true)
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setPriority(priority)
.setCategory(NotificationCompat.CATEGORY_ERROR)
viewItemAction?.let { builder.addAction(it) }
notificationManager.notifyIfPossible(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
}
private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) =
DebugInfoActivity.IntentBuilder(context)
.withAccount(account)
.withAuthority(authority)
.withCause(e)
.withLocalResource(
try {
local?.toString()
} catch (e: OutOfMemoryError) {
// for instance because of a huge contact photo; maybe we're lucky and can fetch it
null
}
)
.withRemoteResource(remote)
.build()
private fun buildViewItemAction(local: ResourceType): NotificationCompat.Action? {
Logger.log.log(Level.FINE, "Adding view action for local resource", local)
val intent = local.id?.let { id ->
when (local) {
is LocalContact ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
is LocalEvent ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
is LocalTask ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
else ->
null
}
}
return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item),
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
else
null
}
protected fun notifyInvalidResource(e: Throwable, fileName: String) {
val intent = buildDebugInfoIntent(e, null, collectionURL.resolve(fileName))
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_WARNINGS)
builder .setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(notifyInvalidResourceTitle())
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
.setSubText(mainAccount.name)
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.priority = NotificationCompat.PRIORITY_LOW
notificationManager.notifyIfPossible(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build())
}
protected abstract fun notifyInvalidResourceTitle(): String
protected fun<T: ResourceType?, R> localExceptionContext(local: T, body: (T) -> R): R {
try {
return body(local)
} catch (e: ContextedException) {
e.addContextValue(EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
throw e
} catch (e: Throwable) {
if (local != null)
throw ContextedException(e).setContextValue(EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
else
throw e
}
}
protected fun<T: DavResource, R> remoteExceptionContext(remote: T, body: (T) -> R): R {
try {
return body(remote)
} catch (e: ContextedException) {
e.addContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
throw e
} catch(e: Throwable) {
throw ContextedException(e).setContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
}
}
protected fun<T> responseExceptionContext(remote: Response, body: (Response) -> T): T {
try {
return body(remote)
} catch (e: ContextedException) {
e.addContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
throw e
} catch (e: Throwable) {
throw ContextedException(e).setContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
}
}
protected fun<R> remoteExceptionContext(body: (RemoteType) -> R) =
remoteExceptionContext(davCollection, body)
private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) {
var ex: Throwable? = null
try {
body()
} catch(e: Throwable) {
ex = e
}
var local: ResourceType? = null
var remote: HttpUrl? = null
if (ex is ContextedException) {
@Suppress("UNCHECKED_CAST")
// we want the innermost context value, which is the first one
(ex.getFirstContextValue(EXCEPTION_CONTEXT_LOCAL_RESOURCE) as? ResourceType)?.let {
local = it
}
(ex.getFirstContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE) as? HttpUrl)?.let {
remote = it
}
ex = ex.cause
}
if (ex != null)
handler(ex, local, remote)
}
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.content.Context
import android.provider.CalendarContract
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.TaskUtils
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Utility methods related to synchronization management (authorities, workers etc.)
*/
object SyncUtils {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface SyncUtilsEntryPoint {
fun appDatabase(): AppDatabase
fun settingsManager(): SettingsManager
}
/**
* Returns a list of all available sync authorities:
*
* 1. calendar authority
* 2. address books authority
* 3. current tasks authority (if available)
*
* 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
*/
fun syncAuthorities(context: Context): List<String> {
val result = mutableListOf(
CalendarContract.AUTHORITY,
context.getString(R.string.address_books_authority)
)
TaskUtils.currentProvider(context)?.let { taskProvider ->
result += taskProvider.authority
}
return result
}
}

View File

@@ -1,109 +0,0 @@
/*
* 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 android.content.Context
import android.content.SyncResult
import android.os.DeadObjectException
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.logging.Level
/**
* Base class for sync code.
*
* Contains generic sync code, equal for all sync authorities, checks sync conditions and does
* validation.
*
* Also provides useful methods that can be used by derived syncers ie [CalendarSyncer], etc.
*/
abstract class Syncer(val context: Context) {
companion object {
/**
* Requests a re-synchronization of all entries. For instance, if this extra is
* set for a calendar sync, all remote events will be listed and checked for remote
* changes again.
*
* Useful if settings which modify the remote resource list (like the CalDAV setting
* "sync events n days in the past") have been changed.
*/
const val SYNC_EXTRAS_RESYNC = "resync"
/**
* Requests a full re-synchronization of all entries. For instance, if this extra is
* set for an address book sync, all contacts will be downloaded again and updated in the
* local storage.
*
* Useful if settings which modify parsing/local behavior have been changed.
*/
const val SYNC_EXTRAS_FULL_RESYNC = "full_resync"
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface SyncerEntryPoint {
fun appDatabase(): AppDatabase
}
private val syncAdapterEntryPoint = EntryPointAccessors.fromApplication(context, SyncerEntryPoint::class.java)
internal val db = syncAdapterEntryPoint.appDatabase()
abstract fun sync(account: Account, extras: Array<String>, authority: String, httpClient: Lazy<HttpClient>, provider: ContentProviderClient, syncResult: SyncResult)
fun onPerformSync(
account: Account,
extras: Array<String>,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult
) {
Logger.log.log(Level.INFO, "$authority sync of $account initiated", extras.joinToString(", "))
val accountSettings by lazy { AccountSettings(context, account) }
val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() }
try {
val runSync = /* ose */ true
if (runSync)
sync(account, extras, authority, httpClient, provider, syncResult)
} catch (e: DeadObjectException) {
/* 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.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e)
syncResult.stats.numIoExceptions++
} catch (e: InvalidAccountException) {
Logger.log.log(Level.WARNING, "Account was removed during synchronization", e)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync $authority", e)
syncResult.stats.numParseExceptions++
} finally {
if (httpClient.isInitialized())
httpClient.value.close()
Logger.log.log(
Level.INFO,
"$authority sync of $account finished",
extras.joinToString(", "))
}
}
}

View File

@@ -1,126 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.util.logging.Level
/**
* Sync logic for tasks in CalDAV collections ({@code VTODO}).
*/
class TaskSyncer(context: Context): Syncer(context) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface TaskSyncerEntryPoint {
fun tasksSyncManagerFactory(): TasksSyncManager.Factory
}
private val entryPoint = EntryPointAccessors.fromApplication<TaskSyncerEntryPoint>(context)
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
try {
val providerName = TaskProvider.ProviderName.fromAuthority(authority)
val taskProvider = TaskProvider.fromProviderClient(context, providerName, provider)
// make sure account can be seen by task provider
if (Build.VERSION.SDK_INT >= 26) {
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
and starts them again! So make sure setAccountVisibility is only called when necessary. */
val am = AccountManager.get(context)
if (am.getAccountVisibility(account, providerName.packageName) != AccountManager.VISIBILITY_VISIBLE)
am.setAccountVisibility(account, providerName.packageName, AccountManager.VISIBILITY_VISIBLE)
}
val accountSettings = AccountSettings(context, account)
updateLocalTaskLists(taskProvider, account, accountSettings)
val taskLists = DmfsTaskList
.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
for (taskList in taskLists) {
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
val syncManagerFactory = entryPoint.tasksSyncManagerFactory()
val syncManager = syncManagerFactory.tasksSyncManager(account, accountSettings, httpClient.value, extras, authority, syncResult, taskList)
syncManager.performSync()
}
} catch (e: TaskProvider.ProviderTooOldException) {
TaskUtils.notifyProviderTooOld(context, e)
syncResult.databaseError = true
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
syncResult.databaseError = true
}
Logger.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
remoteTaskLists[collection.url] = collection
}
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in DmfsTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let {
val url = it.toHttpUrl()
val info = remoteTaskLists[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteTaskLists -= url
}
}
// create new local task lists
for ((_,info) in remoteTaskLists) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
}
}

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