mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-19 12:17:56 -05:00
Compare commits
161 Commits
v4.4-ose
...
debug-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e8c4522b | ||
|
|
d00353ba9c | ||
|
|
dc0d4f371a | ||
|
|
3d198f5454 | ||
|
|
1802740a2d | ||
|
|
138e517d23 | ||
|
|
166b2ac220 | ||
|
|
450a418994 | ||
|
|
d4e9e2a8f7 | ||
|
|
ecc59dda99 | ||
|
|
9c2afbab09 | ||
|
|
cebf2d9dfd | ||
|
|
5f49c675c8 | ||
|
|
62c46e123d | ||
|
|
5f1215801d | ||
|
|
930977c44b | ||
|
|
a0d152a66f | ||
|
|
a8883427bc | ||
|
|
7a8dbef80b | ||
|
|
4a40bb3d6f | ||
|
|
c805e549ff | ||
|
|
26a670c181 | ||
|
|
5b54c9dff0 | ||
|
|
1ca73b67a4 | ||
|
|
194c587476 | ||
|
|
1193027e5f | ||
|
|
7de7980860 | ||
|
|
fc7f42c6fa | ||
|
|
196bfb3aea | ||
|
|
cb5798833d | ||
|
|
a1148613e9 | ||
|
|
12529fa9bd | ||
|
|
d743d19a3d | ||
|
|
4dcee27e22 | ||
|
|
b6ceaa7efc | ||
|
|
5c6f712d32 | ||
|
|
5180b99af2 | ||
|
|
dcb7e315b9 | ||
|
|
111481cd00 | ||
|
|
4dc7df7c53 | ||
|
|
cf609288e1 | ||
|
|
0b9d4cd3b3 | ||
|
|
0581417bba | ||
|
|
f8fb016a27 | ||
|
|
8c3d1cdeae | ||
|
|
4a4dc24cdf | ||
|
|
49a51ef384 | ||
|
|
fc698040aa | ||
|
|
6cbd71ab50 | ||
|
|
47f078dcd7 | ||
|
|
be6ab8728c | ||
|
|
2908bba298 | ||
|
|
b962b68631 | ||
|
|
fca7c09105 | ||
|
|
60c6aba2d2 | ||
|
|
70f6f2603e | ||
|
|
5d4c9c8d94 | ||
|
|
4378bee042 | ||
|
|
3776b50bbc | ||
|
|
a9c7e1929f | ||
|
|
318b9be77e | ||
|
|
26cb845950 | ||
|
|
eae6d0c578 | ||
|
|
6df0925e50 | ||
|
|
f7ee1ea931 | ||
|
|
16731d3a5a | ||
|
|
54e09acca3 | ||
|
|
46698a76b5 | ||
|
|
e26a8519ff | ||
|
|
410c70a47d | ||
|
|
bf1bdfc8ab | ||
|
|
5cbbfb39aa | ||
|
|
62d5a21d05 | ||
|
|
2a7cf1ae17 | ||
|
|
1062eaa58a | ||
|
|
69bde87589 | ||
|
|
4111fe08d2 | ||
|
|
19fb969040 | ||
|
|
74a22cd24d | ||
|
|
4e264076d1 | ||
|
|
c3fe1b04e5 | ||
|
|
342314363b | ||
|
|
d0358a9980 | ||
|
|
fbcf6996ad | ||
|
|
cf994ee82e | ||
|
|
9880dd5158 | ||
|
|
8e7d289971 | ||
|
|
d4c05b9282 | ||
|
|
768f462549 | ||
|
|
cbd9a55c15 | ||
|
|
4e496265e4 | ||
|
|
73d0b63705 | ||
|
|
a361888d94 | ||
|
|
907b38fd6a | ||
|
|
bf0e169cf1 | ||
|
|
fd2b3f0018 | ||
|
|
6217582677 | ||
|
|
d03dc1f37d | ||
|
|
d33e4dcb23 | ||
|
|
f4c02d4ab6 | ||
|
|
fafa358dd8 | ||
|
|
d5d6592ae2 | ||
|
|
ac48e65b1a | ||
|
|
26f95db62a | ||
|
|
5c4d697767 | ||
|
|
59a57fc40a | ||
|
|
50c13e5b6d | ||
|
|
3a38a06302 | ||
|
|
c489002f5c | ||
|
|
7cbc9bd4f5 | ||
|
|
1fd65a4d42 | ||
|
|
b3cc24e4be | ||
|
|
556741ae1e | ||
|
|
b1bcf32535 | ||
|
|
be43d360ba | ||
|
|
0adceb64ec | ||
|
|
5ebef30abb | ||
|
|
3d65afbf8f | ||
|
|
51f01b215c | ||
|
|
c02bf942e4 | ||
|
|
d7025d4e9e | ||
|
|
56f7b4bbc5 | ||
|
|
4a3ebc422f | ||
|
|
4b18302ec7 | ||
|
|
5dd7609524 | ||
|
|
48855c7bb8 | ||
|
|
7a8761f703 | ||
|
|
1dd91a2848 | ||
|
|
90f1c015d2 | ||
|
|
726e20ed52 | ||
|
|
7f750e22cb | ||
|
|
6b6573ddd2 | ||
|
|
8849b363c7 | ||
|
|
005d6b30c0 | ||
|
|
c3436fd23f | ||
|
|
5497e343c0 | ||
|
|
479a2c363a | ||
|
|
18b1e5222e | ||
|
|
d4b4981e26 | ||
|
|
e1f3785bc6 | ||
|
|
e92f261faf | ||
|
|
ea035fa931 | ||
|
|
8167e8e3cb | ||
|
|
bcc16e1ab6 | ||
|
|
28948485f6 | ||
|
|
0985a99ed3 | ||
|
|
1d7084b555 | ||
|
|
64563bbd3a | ||
|
|
0bdeffe70d | ||
|
|
9e0772a9dd | ||
|
|
70e56df80c | ||
|
|
b4756666b6 | ||
|
|
74304bfe17 | ||
|
|
25daa57a6f | ||
|
|
a3ccdc2a46 | ||
|
|
ffefd519b6 | ||
|
|
4823d6d671 | ||
|
|
9b5d5c982b | ||
|
|
a03c83450d | ||
|
|
5d97161c9b | ||
|
|
671c17376a |
2
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
2
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Qualified Feature Request
|
||||
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
|
||||
labels: ["feature"]
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -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
3
.github/release.yml
vendored
@@ -12,6 +12,9 @@ changelog:
|
||||
- title: Refactoring
|
||||
labels:
|
||||
- refactoring
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other changes
|
||||
labels:
|
||||
- "*"
|
||||
|
||||
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
@@ -32,19 +32,12 @@ jobs:
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
- name: Use incremental build cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: incremental-build-tests
|
||||
restore-keys: incremental-build-tests # restore cache from main branch
|
||||
path: app/build
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Prepare keystore
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }}
|
||||
|
||||
- name: Create Github release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ env.prerelease }}
|
||||
files: app/build/outputs/apk/ose/release/*.apk
|
||||
|
||||
71
.github/workflows/test-dev.yml
vendored
71
.github/workflows/test-dev.yml
vendored
@@ -8,58 +8,62 @@ concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
is_main_branch: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile for build cache
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
|
||||
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
|
||||
|
||||
test:
|
||||
name: Tests without emulator
|
||||
needs: compile
|
||||
if: ${{ always() }} # even if compile didn't run (because not on main branch)
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test_on_emulator
|
||||
cache-read-only: true
|
||||
|
||||
- name: Use incremental build cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: incremental-build-tests
|
||||
restore-keys: incremental-build-tests # restore cache from main branch
|
||||
path: app/build
|
||||
- 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: Run lint and unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:check
|
||||
|
||||
# generates the build caches because it uses more gradle dependencies
|
||||
test_on_emulator:
|
||||
name: Tests with emulator
|
||||
needs: compile
|
||||
if: ${{ always() }} # even if compile didn't run (because not on main branch)
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
gradle-home-cache-cleanup: true # avoid ever-growing gradle user home cache
|
||||
|
||||
- name: Use incremental build cache
|
||||
if: ${{ !env.is_main_branch }}
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: incremental-build-tests
|
||||
restore-keys: incremental-build-tests # restore cache from main branch
|
||||
path: |
|
||||
.gradle/configuration-cache
|
||||
app/build
|
||||
cache-read-only: true
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
@@ -74,11 +78,4 @@ jobs:
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
- name: Run device tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:virtualCheck
|
||||
|
||||
- name: Create incremental build cache
|
||||
if: ${{ env.is_main_branch }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
key: incremental-build-tests-${{ github.run_id }}
|
||||
path: app/build
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck
|
||||
|
||||
@@ -13,32 +13,33 @@ plugins {
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404000004
|
||||
versionName = "4.4"
|
||||
|
||||
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
|
||||
versionCode = 404030200
|
||||
versionName = "4.4.3.2"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 34 // Android 14
|
||||
targetSdk = 35 // Android 15
|
||||
|
||||
buildConfigField("String", "userAgent", "\"DAVx5\"")
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.CustomTestRunner"
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// enable because ical4android requires desugaring
|
||||
// required for
|
||||
// - dnsjava 3.x: java.nio.file.Path
|
||||
// - ical4android: time API
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -81,6 +82,9 @@ android {
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -115,6 +119,10 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
excludeFields = arrayOf("generated")
|
||||
}
|
||||
|
||||
configurations {
|
||||
configureEach {
|
||||
// exclude modules which are in conflict with system libraries
|
||||
@@ -141,11 +149,7 @@ dependencies {
|
||||
implementation(libs.androidx.activityCompose)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.cardView)
|
||||
implementation(libs.androidx.concurrentFuture)
|
||||
implementation(libs.androidx.constraintLayout)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
@@ -156,7 +160,6 @@ dependencies {
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.security)
|
||||
implementation(libs.androidx.work.base)
|
||||
implementation(libs.android.flexbox)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(libs.compose.accompanist.permissions)
|
||||
@@ -186,20 +189,16 @@ dependencies {
|
||||
implementation(libs.bitfire.vcard4android)
|
||||
|
||||
// third-party libs
|
||||
implementation(libs.appintro)
|
||||
implementation(libs.commons.collections)
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang)
|
||||
implementation(libs.commons.text)
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.openid.appauth)
|
||||
implementation(libs.unifiedpush)
|
||||
|
||||
// for tests
|
||||
androidTestImplementation(libs.androidx.arch.core.testing)
|
||||
@@ -215,5 +214,6 @@ dependencies {
|
||||
androidTestImplementation(libs.room.testing)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider # dnsjava
|
||||
-dontwarn org.xmlpull.**
|
||||
-dontwarn sun.net.spi.nameservice.NameService
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
|
||||
669
app/schemas/at.bitfire.davdroid.db.AppDatabase/14.json
Normal file
669
app/schemas/at.bitfire.davdroid.db.AppDatabase/14.json
Normal file
@@ -0,0 +1,669 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 14,
|
||||
"identityHash": "9a0eb47f27473eab254db568081a4585",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "ownerId",
|
||||
"columnName": "ownerId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushTopic",
|
||||
"columnName": "pushTopic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsWebPush",
|
||||
"columnName": "supportsWebPush",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscription",
|
||||
"columnName": "pushSubscription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushSubscriptionCreated",
|
||||
"columnName": "pushSubscriptionCreated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_ownerId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"ownerId",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_pushTopic_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"pushTopic",
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "principal",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"ownerId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "principal",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_principal_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authority",
|
||||
"columnName": "authority",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_authority",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"authority"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_document",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mountId",
|
||||
"columnName": "mountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "parentId",
|
||||
"columnName": "parentId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDirectory",
|
||||
"columnName": "isDirectory",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "eTag",
|
||||
"columnName": "eTag",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastModified",
|
||||
"columnName": "lastModified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayBind",
|
||||
"columnName": "mayBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayUnbind",
|
||||
"columnName": "mayUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mayWriteContent",
|
||||
"columnName": "mayWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaAvailable",
|
||||
"columnName": "quotaAvailable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "quotaUsed",
|
||||
"columnName": "quotaUsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_webdav_document_mountId_parentId_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"mountId",
|
||||
"parentId",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_webdav_document_parentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"parentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "webdav_mount",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"mountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "webdav_document",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"parentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "webdav_mount",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a0eb47f27473eab254db568081a4585')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,21 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
</manifest>
|
||||
<application>
|
||||
|
||||
<!-- test account type (without associated sync adapters) -->
|
||||
<service
|
||||
android:name="at.bitfire.davdroid.sync.account.TestAccountAuthenticator"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/test_account_authenticator"/>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class CustomTestRunner : AndroidJUnitRunner() {
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
|
||||
override fun newApplication(cl: ClassLoader, name: String, context: Context) =
|
||||
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
|
||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
|
||||
}
|
||||
@@ -6,91 +6,118 @@ package at.bitfire.davdroid
|
||||
|
||||
import android.Manifest
|
||||
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 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.Assert.assertNotNull
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* JUnit ClassRule which initializes the AOSP CalendarProvider.
|
||||
* Needed for some "flaky" tests which would otherwise only succeed on second run.
|
||||
*
|
||||
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
|
||||
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
|
||||
* if this comment) can find out on how to initialize the calendar provider so that the
|
||||
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
|
||||
* please let us know!
|
||||
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
|
||||
* maybe by some wrongly synchronized database initialization. So things like querying the instances
|
||||
* fails in this case.
|
||||
*
|
||||
* If you run tests manually, just make sure to ignore the first run after the calendar
|
||||
* provider has been accessed the first time.
|
||||
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
|
||||
* is used the very first time (especially in CI tests / a fresh emulator).
|
||||
*
|
||||
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
|
||||
*/
|
||||
class InitCalendarProviderRule private constructor(): TestRule {
|
||||
class InitCalendarProviderRule private constructor(): ExternalResource() {
|
||||
|
||||
companion object {
|
||||
fun getInstance() = RuleChain
|
||||
|
||||
private var isInitialized = false
|
||||
private val logger = Logger.getLogger(InitCalendarProviderRule::javaClass.name)
|
||||
|
||||
fun getInstance(): RuleChain = 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() {
|
||||
override fun before() {
|
||||
if (!isInitialized) {
|
||||
logger.info("Initializing calendar provider")
|
||||
if (Build.VERSION.SDK_INT < 31)
|
||||
Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
|
||||
initCalendarProvider()
|
||||
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
|
||||
|
||||
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!!)
|
||||
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
|
||||
assertNotNull("Couldn't acquire calendar provider", client)
|
||||
|
||||
// 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()
|
||||
client!!.use {
|
||||
initCalendarProvider(client)
|
||||
isInitialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initCalendarProvider(provider: ContentProviderClient) {
|
||||
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
|
||||
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
|
||||
var calendarOrNull: LocalCalendar? = null
|
||||
for (i in 0..50) {
|
||||
calendarOrNull = createAndVerifyCalendar(account, provider)
|
||||
if (calendarOrNull != null)
|
||||
break
|
||||
else
|
||||
Thread.sleep(100)
|
||||
}
|
||||
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
|
||||
|
||||
try {
|
||||
// single event init
|
||||
val normalEvent = Event().apply {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
|
||||
return try {
|
||||
AndroidCalendar.findByID(
|
||||
account,
|
||||
provider,
|
||||
LocalCalendar.Factory,
|
||||
ContentUris.parseId(uri)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warning("Couldn't find calendar after creation: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.Request
|
||||
@@ -21,6 +22,10 @@ class OkhttpClientTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@@ -32,8 +37,7 @@ class OkhttpClientTest {
|
||||
|
||||
@Test
|
||||
fun testIcloudWithSettings() {
|
||||
val client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
.build()
|
||||
val client = HttpClient.Builder(context).build()
|
||||
client.okHttpClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
@@ -0,0 +1,38 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorker
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.startup.TasksAppWatcher
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
interface TestModules {
|
||||
|
||||
// remove PushRegistrationWorkerModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
|
||||
)
|
||||
abstract class TestPushRegistrationWorkerModule {
|
||||
// provides empty set of listeners
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
|
||||
}
|
||||
|
||||
// remove TasksAppWatcherModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
|
||||
)
|
||||
abstract class TestTasksAppWatcherModuleModule {
|
||||
// provides empty set of plugins
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<StartupPlugin>
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
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.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
@@ -33,26 +34,26 @@ class CollectionTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@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()
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = HttpClient.Builder(context).build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
fun teardown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ class Android10ResolverTest {
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
|
||||
fun testResolveA() {
|
||||
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance(Inet4Address::class.java).first()
|
||||
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance<Inet4Address>().first()
|
||||
|
||||
val srvLookup = Lookup(FQDN_DAVX5, Type.A)
|
||||
srvLookup.setResolver(Android10Resolver)
|
||||
srvLookup.setResolver(Android10Resolver())
|
||||
val resultGeneric = srvLookup.run()
|
||||
assertEquals(1, resultGeneric.size)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DnsRecordResolverTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var dnsRecordResolver: DnsRecordResolver
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_Empty() {
|
||||
assertNull(dnsRecordResolver.bestSRVRecord(emptyArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_MultipleRecords_Priority_Different() {
|
||||
val dns1010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
|
||||
)
|
||||
val dns2010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 20, 20, 8443, Name.fromString("dav2010.example.com.")
|
||||
)
|
||||
|
||||
// lowest priority first
|
||||
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns2010))
|
||||
assertEquals(dns1010, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_MultipleRecords_Priority_Same() {
|
||||
val dns1010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
|
||||
)
|
||||
val dns1020 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.")
|
||||
)
|
||||
|
||||
// entries are selected randomly (for load balancing)
|
||||
// run 1000 times to get a good distribution
|
||||
val counts = IntArray(2)
|
||||
for (i in 0 until 1000) {
|
||||
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns1020))
|
||||
|
||||
when (result) {
|
||||
dns1010 -> counts[0]++
|
||||
dns1020 -> counts[1]++
|
||||
}
|
||||
}
|
||||
|
||||
/* We had weights 10 and 20, so the distribution of 1000 tries should be roughly
|
||||
weight 10 fraction 1/3 expected count 333 binomial distribution (p=1/3) with 99.99% in [275..393]
|
||||
weight 20 fraction 2/3 expected count 667 binomial distribution (p=2/3) with 99.99% in [607..725]
|
||||
*/
|
||||
assertTrue(counts[0] in 275..393)
|
||||
assertTrue(counts[1] in 607..725)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_OneRecord() {
|
||||
val dns1010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
|
||||
)
|
||||
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010))
|
||||
assertEquals(dns1010, result)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPathsFromTXTRecords_Empty() {
|
||||
assertTrue(dnsRecordResolver.pathsFromTXTRecords(arrayOf()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPathsFromTXTRecords_OnePath() {
|
||||
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
|
||||
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("something=else", "path=/path1"))
|
||||
)).toTypedArray()
|
||||
assertArrayEquals(arrayOf("/path1"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPathsFromTXTRecords_TwoPaths() {
|
||||
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
|
||||
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("path=/path1", "something-else", "path=/path2"))
|
||||
)).toTypedArray()
|
||||
result.sort()
|
||||
assertArrayEquals(arrayOf("/path1", "/path2"), result)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.Request
|
||||
@@ -14,10 +15,12 @@ import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
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 HttpClientTest {
|
||||
@@ -28,11 +31,15 @@ class HttpClientTest {
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
httpClient = HttpClient.Builder(context).build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
@@ -70,7 +77,8 @@ class HttpClientTest {
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
|
||||
val header = server.takeRequest().getHeader("Cookie")
|
||||
assertTrue(header == "cookie1=1; cookie2=2" || header == "cookie2=2; cookie1=1")
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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 accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
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(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
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.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -15,67 +16,63 @@ import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomesetDaoTest {
|
||||
class DavHomeSetRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
lateinit var repository: DavHomeSetRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val serviceId = createTestService()
|
||||
val homeSetDao = db.homeSetDao()
|
||||
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = homeSetDao.insertOrUpdateByUrl(entry1)
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.apply { id = 1L }, homeSetDao.getById(1L))
|
||||
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 = homeSetDao.insertOrUpdateByUrl(updatedEntry1)
|
||||
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.apply { id = 1L }, homeSetDao.getById(1L))
|
||||
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 = homeSetDao.insertOrUpdateByUrl(entry2)
|
||||
val insertId2 = repository.insertOrUpdateByUrl(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.apply { id = 2L }, homeSetDao.getById(2L))
|
||||
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
// should delete row with given primary key (id)
|
||||
val serviceId = createTestService()
|
||||
val homesetDao = db.homeSetDao()
|
||||
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = homesetDao.insertOrUpdateByUrl(entry1)
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, homesetDao.getById(1L))
|
||||
assertEquals(entry1, repository.getById(1L))
|
||||
|
||||
homesetDao.delete(entry1)
|
||||
|
||||
assertEquals(null, homesetDao.getById(1L))
|
||||
repository.delete(entry1)
|
||||
assertEquals(null, repository.getById(1L))
|
||||
}
|
||||
|
||||
fun createTestService() : Long {
|
||||
val serviceDao = db.serviceDao()
|
||||
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
|
||||
return serviceDao.insertOrReplace(service)
|
||||
|
||||
private fun createTestService() : Long {
|
||||
val service = Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
return serviceRepository.insertOrReplace(service)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,15 +4,31 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.LabeledProperty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import ezvcard.property.Telephone
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@@ -22,57 +38,114 @@ class LocalAddressBookTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
val mainAccountType = context.getString(R.string.account_type)
|
||||
val mainAccount = Account("main", mainAccountType)
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
val addressBookAccount = Account("sub", addressBookAccountType)
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
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)))
|
||||
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
LocalTestAddressBook.createAccount(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanup() {
|
||||
accountManager.removeAccount(addressBookAccount, null, null)
|
||||
accountManager.removeAccount(mainAccount, null, null)
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
addressBook.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
}))
|
||||
/**
|
||||
* Tests whether contacts are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsContacts() {
|
||||
// insert contact with data row
|
||||
val uid = "12345"
|
||||
val contact = Contact(
|
||||
uid = uid,
|
||||
displayName = "Test Contact",
|
||||
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
|
||||
)
|
||||
val uri = LocalContact(addressBook, contact, null, null, 0).add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
val localContact = addressBook.findContactById(id)
|
||||
localContact.resetDirty()
|
||||
assertFalse("Contact is dirty before moving", addressBook.isContactDirty(id))
|
||||
|
||||
// check mainAccount()
|
||||
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
|
||||
// check whether contact is still here (including data rows) and not dirty
|
||||
val result = addressBook.findContactById(id)
|
||||
assertFalse("Contact is dirty after moving", addressBook.isContactDirty(id))
|
||||
|
||||
val contact2 = result.getContact()
|
||||
assertEquals(uid, contact2.uid)
|
||||
assertEquals("Test Contact", contact2.displayName)
|
||||
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle()))
|
||||
/**
|
||||
* Tests whether groups are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsGroups() {
|
||||
// insert group
|
||||
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
|
||||
val uri = localGroup.add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
|
||||
// check mainAccount(); should fail because there's no main account
|
||||
LocalAddressBook.mainAccount(context, addressBookAccount)
|
||||
}*/
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_OtherAccount() {
|
||||
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
|
||||
|
||||
val group = result.getContact()
|
||||
assertEquals("Test Group", group.displayName)
|
||||
}
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
assertNotNull(provider)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,19 +15,25 @@ 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 at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
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.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
|
||||
class LocalCalendarTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
@@ -36,14 +42,14 @@ class LocalCalendarTest {
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpProvider() {
|
||||
fun setUpClass() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun closeProvider() {
|
||||
fun tearDownClass() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
@@ -53,14 +59,14 @@ class LocalCalendarTest {
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun prepare() {
|
||||
fun setUp() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutdown() {
|
||||
calendar.delete()
|
||||
fun tearDown() {
|
||||
calendar.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,11 +21,22 @@ 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 net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.ExDate
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import java.util.*
|
||||
import java.util.UUID
|
||||
|
||||
class LocalEventTest {
|
||||
|
||||
@@ -35,34 +46,35 @@ class LocalEventTest {
|
||||
@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() {
|
||||
fun setUpClass() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
fun tearDownClass() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun createCalendar() {
|
||||
fun setUp() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.delete()
|
||||
calendar.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,77 +8,79 @@ import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
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.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class 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()
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
}, null, null, 0
|
||||
).apply {
|
||||
add()
|
||||
}
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@Before
|
||||
fun clearContacts() {
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
// clear contacts
|
||||
addressBookGroupsAsCategories.clear()
|
||||
addressBookGroupsAsVCards.clear()
|
||||
}
|
||||
@@ -264,4 +266,16 @@ class LocalGroupTest {
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
}, null, null, 0
|
||||
).apply {
|
||||
add()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,24 +5,38 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTestAddressBook(
|
||||
context: Context,
|
||||
provider: ContentProviderClient,
|
||||
override val groupMethod: GroupMethod
|
||||
): LocalAddressBook(context, ACCOUNT, provider) {
|
||||
class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository
|
||||
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
|
||||
|
||||
companion object {
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
}
|
||||
|
||||
override var mainAccount: Account?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = false
|
||||
set(_) = throw NotImplementedError()
|
||||
@@ -35,4 +49,49 @@ class LocalTestAddressBook(
|
||||
group.delete()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the dirty flag of the given contact.
|
||||
*
|
||||
* @return true if the contact is dirty, false otherwise
|
||||
*
|
||||
* @throws FileNotFoundException if the contact can't be found
|
||||
*/
|
||||
fun isContactDirty(id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(rawContactsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dirty flag of the given contact group.
|
||||
*
|
||||
* @return true if the group is dirty, false otherwise
|
||||
*
|
||||
* @throws FileNotFoundException if the group can't be found
|
||||
*/
|
||||
fun isGroupDirty(id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(groupsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
|
||||
fun createAccount(context: Context) {
|
||||
val am = AccountManager.get(context)
|
||||
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource.contactrow
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
@@ -15,9 +16,19 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CachedGroupMembershipHandlerTest {
|
||||
|
||||
companion object {
|
||||
@@ -27,30 +38,43 @@ class CachedGroupMembershipHandlerTest {
|
||||
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()
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
@@ -14,38 +15,57 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class 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
|
||||
val context: 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()
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +74,7 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
|
||||
@@ -66,6 +87,7 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource.contactrow
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
@@ -15,44 +16,67 @@ 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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class GroupMembershipHandlerTest {
|
||||
|
||||
@JvmField
|
||||
@Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
companion object {
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private var addressBookGroupsAsCategoriesGroup: Long = -1
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
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()
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
@@ -66,6 +90,8 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
|
||||
@@ -6,31 +6,23 @@ package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
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 at.bitfire.davdroid.ui.NotificationUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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 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
|
||||
@@ -39,39 +31,11 @@ import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class RefreshCollectionsWorkerTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// 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)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
|
||||
// Test dependencies
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
@@ -86,45 +50,47 @@ class RefreshCollectionsWorkerTest {
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionListRefresher.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
var mockServer = MockWebServer()
|
||||
|
||||
lateinit var client: HttpClient
|
||||
private val mockServer = MockWebServer()
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun mockServerSetup() {
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher()
|
||||
mockServer.dispatcher = TestDispatcher(logger)
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
client = HttpClient.Builder(context).build()
|
||||
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
fun teardown() {
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
}
|
||||
|
||||
|
||||
// Actual tests
|
||||
|
||||
/* Often fails for unknown reasons:
|
||||
@Test
|
||||
fun testRefreshCollections_enqueuesWorker() {
|
||||
val service = createTestService(Service.TYPE_CALDAV)!!
|
||||
|
||||
val (workerName, enqueueOp) = RefreshCollectionsWorker.enqueue(context, service.id)
|
||||
enqueueOp.result.get()
|
||||
assertTrue(workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}*/
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
@@ -132,8 +98,7 @@ class RefreshCollectionsWorkerTest {
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.discoverHomesets(baseUrl)
|
||||
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)
|
||||
@@ -153,8 +118,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
@@ -191,8 +155,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
@@ -231,8 +194,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
@@ -273,9 +235,8 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
// 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)
|
||||
@@ -304,8 +265,7 @@ class RefreshCollectionsWorkerTest {
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
@@ -338,8 +298,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomelessCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
@@ -374,8 +333,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomelessCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
@@ -399,8 +357,7 @@ class RefreshCollectionsWorkerTest {
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomelessCollections()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
@@ -442,8 +399,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshPrincipals()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
@@ -478,8 +434,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshPrincipals()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
@@ -502,8 +457,7 @@ class RefreshCollectionsWorkerTest {
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshPrincipals()
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
@@ -516,46 +470,48 @@ class RefreshCollectionsWorkerTest {
|
||||
fun shouldPreselect_none() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
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 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 = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
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 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 = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -563,69 +519,72 @@ class RefreshCollectionsWorkerTest {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
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 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 = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
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 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 = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
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 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 = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -633,23 +592,24 @@ class RefreshCollectionsWorkerTest {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
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 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 = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
// Test helpers and dependencies
|
||||
@@ -660,10 +620,13 @@ class RefreshCollectionsWorkerTest {
|
||||
return db.serviceDao().get(serviceId)
|
||||
}
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = StringUtils.removeEnd(request.path!!, "/")
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
@@ -741,8 +704,8 @@ class RefreshCollectionsWorkerTest {
|
||||
"</multistatus>"
|
||||
}
|
||||
|
||||
Logger.log.info("Queried: $path")
|
||||
Logger.log.info("Response: $responseBody")
|
||||
logger.info("Queried: $path")
|
||||
logger.info("Response: $responseBody")
|
||||
return MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setBody(responseBody)
|
||||
@@ -752,4 +715,4 @@ class RefreshCollectionsWorkerTest {
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,17 @@
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.content.Context
|
||||
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.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
@@ -32,22 +32,12 @@ import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavResourceFinderTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PATH_NO_DAV = "/nodav"
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
@@ -59,21 +49,39 @@ class DavResourceFinderTest {
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
|
||||
}
|
||||
|
||||
val server = MockWebServer()
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
lateinit var finder: DavResourceFinder
|
||||
lateinit var client: HttpClient
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
private val server = MockWebServer()
|
||||
|
||||
private lateinit var finder: DavResourceFinder
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun initServerAndClient() {
|
||||
server.dispatcher = TestDispatcher()
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
server.dispatcher = TestDispatcher(logger)
|
||||
server.start()
|
||||
|
||||
val baseURI = URI.create("/")
|
||||
val credentials = Credentials("mock", "12345")
|
||||
|
||||
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, baseURI, credentials)
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
finder = resourceFinderFactory.create(baseURI, credentials)
|
||||
client = HttpClient.Builder(context)
|
||||
.addAuthentication(null, credentials)
|
||||
.build()
|
||||
|
||||
@@ -81,7 +89,7 @@ class DavResourceFinderTest {
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@@ -156,7 +164,9 @@ class DavResourceFinderTest {
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(request)) {
|
||||
@@ -205,7 +215,7 @@ class DavResourceFinderTest {
|
||||
|
||||
else -> props = null
|
||||
}
|
||||
Logger.log.info("Sending props: $props")
|
||||
logger.info("Sending props: $props")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
|
||||
@@ -1,140 +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.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.syncadapter.AccountUtils
|
||||
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
/*@HiltAndroidTest
|
||||
class AccountSettingsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
|
||||
val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
val authorities = listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
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
|
||||
@RequiresApi(22)
|
||||
fun removeAccount() {
|
||||
AccountManager.get(context).removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val presetIntervals =
|
||||
context.resources.getStringArray(R.array.settings_sync_interval_seconds)
|
||||
.map { it.toLong() }
|
||||
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
|
||||
for (interval in presetIntervals) {
|
||||
assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval))
|
||||
assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_Syncable() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 15*60L // 15 min
|
||||
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testSyncIntervals_TooShort() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 60L // 1 min is not supported by Android
|
||||
settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_activatesPeriodicSyncWorker() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 15*60L
|
||||
for (authority in authorities) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
assertTrue(settings.setSyncInterval(authority, interval))
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
|
||||
assertEquals(interval, settings.getSyncInterval(authority))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_disablesPeriodicSyncWorker() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = AccountSettings.SYNC_INTERVAL_MANUALLY // -1
|
||||
for (authority in authorities) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
assertTrue(settings.setSyncInterval(authority, interval))
|
||||
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
|
||||
assertEquals(AccountSettings.SYNC_INTERVAL_MANUALLY, settings.getSyncInterval(authority))
|
||||
}
|
||||
}
|
||||
|
||||
}*/
|
||||
@@ -2,12 +2,14 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
|
||||
class LocalTestCollection: LocalCollection<LocalTestResource> {
|
||||
class LocalTestCollection(
|
||||
override val collectionUrl: String = "http://example.com/test/"
|
||||
): LocalCollection<LocalTestResource> {
|
||||
|
||||
override val tag = "LocalTestCollection"
|
||||
override val title = "Local Test Collection"
|
||||
@@ -19,6 +21,8 @@ class LocalTestCollection: LocalCollection<LocalTestResource> {
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun deleteCollection(): Boolean = true
|
||||
|
||||
override fun findDeleted() = entries.filter { it.deleted }
|
||||
override fun findDirty() = entries.filter { it.dirty }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.Awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncAdapterServicesTest {
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncConditionsFactory: SyncConditions.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
|
||||
@get:Rule
|
||||
val timeoutRule: Timeout = Timeout.seconds(5)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
private fun syncAdapter(
|
||||
syncWorkerManager: SyncWorkerManager
|
||||
): SyncAdapterService.SyncAdapter =
|
||||
SyncAdapterService.SyncAdapter(
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
context = context,
|
||||
logger = logger,
|
||||
syncConditionsFactory = syncConditionsFactory,
|
||||
syncWorkerManager = syncWorkerManager
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
runBlocking {
|
||||
val sync = launch {
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
|
||||
// simulate incoming cancellation from sync framework
|
||||
syncAdapter.onSyncCanceled()
|
||||
|
||||
// wait for sync to finish (should happen immediately)
|
||||
sync.join()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function
|
||||
// immediate timeout (instead of really waiting)
|
||||
coEvery { withTimeout(any<Long>(), any<suspend CoroutineScope.() -> Unit>()) } throws CancellationException("Simulated timeout")
|
||||
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_runsInTime() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker immediately returns with success
|
||||
val success = mockk<WorkInfo>()
|
||||
every { success.state } returns WorkInfo.State.SUCCEEDED
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow {
|
||||
emit(listOf(success))
|
||||
delay(60000) // keep the flow active
|
||||
}
|
||||
|
||||
// should just run
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,120 +2,102 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.dav4jvm.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils.assertWithin
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
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 java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncManagerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val account = Account("SyncManagerTest", context.getString(R.string.account_type))
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun createAccount() {
|
||||
assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"))))
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun removeAccount() {
|
||||
assertTrue(AccountManager.get(context).removeAccount(account, null, null).getResult(10, TimeUnit.SECONDS))
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SyncManagerTestModule {
|
||||
@Provides
|
||||
fun davSyncStatsRepository(): DavSyncStatsRepository = mockk<DavSyncStatsRepository>(relaxed = true)
|
||||
}
|
||||
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
val server = MockWebServer()
|
||||
lateinit var account: Account
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// 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)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
|
||||
private fun syncManager(collection: LocalTestCollection, syncResult: SyncResult = SyncResult()) =
|
||||
TestSyncManager(
|
||||
context,
|
||||
account,
|
||||
arrayOf(),
|
||||
"TestAuthority",
|
||||
HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build(),
|
||||
syncResult,
|
||||
collection,
|
||||
server
|
||||
)
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
@@ -527,4 +509,25 @@ class SyncManagerTest {
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncManager(
|
||||
localCollection: LocalTestCollection,
|
||||
syncResult: SyncResult = SyncResult(),
|
||||
collection: Collection = mockk<Collection>() {
|
||||
every { id } returns 1
|
||||
every { url } returns server.url("/")
|
||||
}
|
||||
) = syncManagerFactory.create(
|
||||
account,
|
||||
accountSettingsFactory.create(account),
|
||||
arrayOf(),
|
||||
"TestAuthority",
|
||||
HttpClient.Builder(context).build(),
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var testSyncer: TestSyncer.Factory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
private lateinit var syncer: TestSyncer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_fails() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns false
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
// Should stop the sync after prepare returns false
|
||||
syncer.sync(provider)
|
||||
verify(exactly = 1) { syncer.prepare(provider) }
|
||||
verify(exactly = 0) { syncer.getSyncEnabledCollections() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_succeeds() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns true
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
// Should continue the sync after prepare returns true
|
||||
syncer.sync(provider)
|
||||
verify(exactly = 1) { syncer.prepare(provider) }
|
||||
verify(exactly = 1) { syncer.getSyncEnabledCollections() }
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_deletesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
every { localCollection.collectionUrl } returns "http://delete.the/collection"
|
||||
every { localCollection.deleteCollection() } returns true
|
||||
every { localCollection.title } returns "Collection to be deleted locally"
|
||||
|
||||
// Should delete the localCollection if dbCollection (remote) does not exist
|
||||
val localCollections = mutableListOf(localCollection)
|
||||
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
|
||||
verify(exactly = 1) { localCollection.deleteCollection() }
|
||||
|
||||
// Updated local collection list should be empty
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_updatesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
val dbCollections = mapOf("http://update.the/collection".toHttpUrl() to dbCollection)
|
||||
every { dbCollection.url } returns "http://update.the/collection".toHttpUrl()
|
||||
every { localCollection.collectionUrl } returns "http://update.the/collection"
|
||||
every { localCollection.title } returns "The Local Collection"
|
||||
|
||||
// Should update the localCollection if it exists
|
||||
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
|
||||
|
||||
// Updated local collection list should be same as input
|
||||
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_findsNewCollection() {
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
val dbCollections = mapOf(dbCollection.url to dbCollection)
|
||||
|
||||
// Should return the new collection, because it was not updated
|
||||
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
|
||||
|
||||
// Updated local collection list contain new entry
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCreateLocalCollections() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { syncer.create(provider, dbCollection) } returns localCollection
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
|
||||
// Should return list of newly created local collections
|
||||
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
|
||||
assertEquals(listOf(localCollection), result)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncCollectionContents() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val dbCollection1 = mockk<Collection>()
|
||||
val dbCollection2 = mockk<Collection>()
|
||||
val dbCollections = mapOf(
|
||||
"http://newly.found/collection1".toHttpUrl() to dbCollection1,
|
||||
"http://newly.found/collection2".toHttpUrl() to dbCollection2
|
||||
)
|
||||
val localCollection1 = mockk<LocalTestCollection>()
|
||||
val localCollection2 = mockk<LocalTestCollection>()
|
||||
val localCollections = listOf(localCollection1, localCollection2)
|
||||
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
|
||||
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
|
||||
|
||||
// Should call the collection content sync on both collections
|
||||
syncer.syncCollectionContents(provider, localCollections, dbCollections)
|
||||
verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) }
|
||||
verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) }
|
||||
}
|
||||
|
||||
|
||||
// Test helpers
|
||||
|
||||
class TestSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult
|
||||
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
|
||||
}
|
||||
|
||||
override val authority: String
|
||||
get() = ""
|
||||
override val serviceType: String
|
||||
get() = ""
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean =
|
||||
true
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
|
||||
emptyList()
|
||||
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
emptyList()
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
|
||||
LocalTestCollection(remoteCollection.url.toString())
|
||||
|
||||
override fun syncCollection(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
remoteCollection: Collection
|
||||
) {}
|
||||
|
||||
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,41 +2,63 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class TestSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: HttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
val mockWebServer: MockWebServer
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(context, account, AccountSettings(context, account), httpClient, extras, authority, syncResult, localCollection) {
|
||||
class TestSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted accountSettings: AccountSettings,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted authority: String,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTestCollection,
|
||||
@Assisted collection: Collection
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
|
||||
account,
|
||||
accountSettings,
|
||||
httpClient,
|
||||
extras,
|
||||
authority,
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: HttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
collection: Collection
|
||||
): TestSyncManager
|
||||
}
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = mockWebServer.url("/")
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collectionURL)
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.test.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -17,7 +19,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@@ -26,33 +27,40 @@ class AccountUtilsTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
val testContext = InstrumentationRegistry.getInstrumentation().context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
val account = Account(
|
||||
"AccountUtilsTest",
|
||||
testContext.getString(R.string.account_type_test)
|
||||
)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
val context by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
|
||||
|
||||
@Test
|
||||
fun testCreateAccount() {
|
||||
val userData = Bundle(2)
|
||||
userData.putString("int", "1")
|
||||
userData.putString("string", "abc/\"-")
|
||||
|
||||
val manager = AccountManager.get(context)
|
||||
try {
|
||||
assertTrue(AccountUtils.createAccount(context, account, userData))
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
|
||||
|
||||
// validate user data
|
||||
val manager = AccountManager.get(context)
|
||||
assertEquals("1", manager.getUserData(account, "int"))
|
||||
assertEquals("abc/\"-", manager.getUserData(account, "string"))
|
||||
} finally {
|
||||
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
|
||||
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
|
||||
assertTrue(manager.removeAccountExplicitly(account))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountsCleanupWorkerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var accountManager: AccountManager
|
||||
lateinit var addressBookAccountType: String
|
||||
lateinit var addressBookAccount: Account
|
||||
lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
service = createTestService(Service.TYPE_CARDDAV)
|
||||
|
||||
// Prepare test account
|
||||
accountManager = AccountManager.get(context)
|
||||
addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
addressBookAccount = Account(
|
||||
"Fancy address book account",
|
||||
addressBookAccountType
|
||||
)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Remove the account here in any case; Nice to have when the test fails
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
|
||||
// Create address book account without corresponding collection
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was deleted
|
||||
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
|
||||
// Create address book account _with_ corresponding collection and verify
|
||||
val randomCollectionId = 12345L
|
||||
val userData = Bundle(1).apply {
|
||||
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
|
||||
|
||||
// Create the collection
|
||||
val collectionDao = db.collectionDao()
|
||||
collectionDao.insert(Collection(
|
||||
randomCollectionId,
|
||||
serviceId = service.id,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = "http://www.example.com/yay.php".toHttpUrl()
|
||||
))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun createTestService(serviceType: String): Service {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.test.R
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
/**
|
||||
* Handles the test account type, which has no sync adapters and side effects that run unintentionally.
|
||||
*
|
||||
* Usually used like this:
|
||||
*
|
||||
* ```
|
||||
* lateinit var account: Account
|
||||
*
|
||||
* @Before
|
||||
* fun setUp() {
|
||||
* account = TestAccountAuthenticator.create()
|
||||
*
|
||||
* // You can now use the test account.
|
||||
* }
|
||||
*
|
||||
* @After
|
||||
* fun tearDown() {
|
||||
* TestAccountAuthenticator.remove(account)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class TestAccountAuthenticator: Service() {
|
||||
|
||||
companion object {
|
||||
|
||||
val context by lazy { InstrumentationRegistry.getInstrumentation().context }
|
||||
|
||||
/**
|
||||
* Creates a test account, usually in the `Before` setUp of a test.
|
||||
*
|
||||
* Remove it with [remove].
|
||||
*/
|
||||
fun create(): Account {
|
||||
val accountType = context.getString(R.string.account_type_test)
|
||||
val account = Account("Test Account", accountType)
|
||||
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a test account, usually in the `@After` tearDown of a test.
|
||||
*/
|
||||
fun remove(account: Account) {
|
||||
val am = AccountManager.get(context)
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?) = null
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
|
||||
override fun getAuthTokenLabel(p0: String?) = null
|
||||
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
|
||||
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.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.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.test.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PeriodicSyncWorkerTest {
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
val testContext = InstrumentationRegistry.getInstrumentation().context
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() {
|
||||
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.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 android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.spyk
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncConditionsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@MockK
|
||||
lateinit var capabilities: NetworkCapabilities
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var factory: SyncConditions.Factory
|
||||
|
||||
@MockK
|
||||
lateinit var network1: Network
|
||||
|
||||
@MockK
|
||||
lateinit var network2: Network
|
||||
|
||||
|
||||
private lateinit var accountSettings: AccountSettings
|
||||
|
||||
private lateinit var conditions: SyncConditions
|
||||
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
// prepare accountSettings with some necessary data
|
||||
accountSettings = mockk<AccountSettings> {
|
||||
every { account } returns Account("test", "test")
|
||||
every { getIgnoreVpns() } returns false // default value
|
||||
}
|
||||
|
||||
conditions = factory.create(accountSettings)
|
||||
|
||||
connectivityManager = context.getSystemService<ConnectivityManager>()!!.also { cm ->
|
||||
mockkObject(cm)
|
||||
every { cm.allNetworks } returns arrayOf(network1, network2)
|
||||
every { cm.getNetworkInfo(network1) } returns mockk()
|
||||
every { cm.getNetworkInfo(network2) } returns mockk()
|
||||
every { cm.getNetworkCapabilities(network1) } returns capabilities
|
||||
every { cm.getNetworkCapabilities(network2) } returns capabilities
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_CorrectWiFiSsid() {
|
||||
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(conditions.correctWifiSsid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_WrongWiFiSsid() {
|
||||
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(conditions.correctWifiSsid())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Internet() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Validated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_InternetValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns() {
|
||||
every { accountSettings.getIgnoreVpns() } returns true
|
||||
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(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns_NotVpn() {
|
||||
every { accountSettings.getIgnoreVpns() } returns true
|
||||
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(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@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(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotVpn() {
|
||||
every { accountSettings.getIgnoreVpns() } returns true
|
||||
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(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifi() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_validated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifiValidated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_withoutWifi() {
|
||||
// "Sync only over Wi-Fi" is disabled
|
||||
every { accountSettings.getSyncWifiOnly() } returns false
|
||||
|
||||
assertTrue(factory.create(accountSettings).wifiConditionsMet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
|
||||
// "Sync only over Wi-Fi" is enabled
|
||||
every { accountSettings.getSyncWifiOnly() } returns true
|
||||
|
||||
// Wi-Fi is available
|
||||
mockkObject(conditions) {
|
||||
// Wi-Fi is available
|
||||
every { conditions.wifiAvailable() } returns true
|
||||
|
||||
// Wi-Fi SSID is correct
|
||||
every { conditions.correctWifiSsid() } returns true
|
||||
|
||||
assertTrue(conditions.wifiConditionsMet())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
|
||||
// "Sync only over Wi-Fi" is enabled
|
||||
every { accountSettings.getSyncWifiOnly() } returns true
|
||||
|
||||
mockkObject(conditions) {
|
||||
// Wi-Fi is not available
|
||||
every { conditions.wifiAvailable() } returns false
|
||||
|
||||
// Wi-Fi SSID is correct
|
||||
every { conditions.correctWifiSsid() } returns true
|
||||
|
||||
assertFalse(conditions.wifiConditionsMet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncWorkerManagerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
// one-time sync workers
|
||||
|
||||
@Test
|
||||
fun testEnqueueOneTime() {
|
||||
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
|
||||
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
|
||||
assertEquals(workerName, returnedName)
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
|
||||
|
||||
// periodic sync workers
|
||||
|
||||
@Test
|
||||
fun enablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
|
||||
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,13 +6,12 @@ package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavMount
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.CookieJar
|
||||
@@ -31,33 +30,41 @@ import javax.inject.Inject
|
||||
@HiltAndroidTest
|
||||
class DavDocumentsProviderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject lateinit var db: AppDatabase
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var logger: java.util.logging.Logger
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
private var mockServer = MockWebServer()
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@Before
|
||||
fun mockServerSetup() {
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher()
|
||||
mockServer.dispatcher = TestDispatcher(logger)
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
client = HttpClient.Builder(context).build()
|
||||
|
||||
// mock server delivers HTTP without encryption
|
||||
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
@@ -79,7 +86,7 @@ class DavDocumentsProviderTest {
|
||||
val cookieStore = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
// Query
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
|
||||
// Assert new children were inserted into db
|
||||
@@ -113,7 +120,7 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
|
||||
|
||||
// Query - should update the parent displayname and folder name
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
|
||||
// Assert parent and children were updated in database
|
||||
@@ -138,7 +145,7 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
|
||||
// Query - discovers serverside deletion
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent)
|
||||
|
||||
// Assert folder got deleted
|
||||
@@ -162,9 +169,9 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("parent2", parent2.name)
|
||||
|
||||
// Query - find children of two nodes simultaneously
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent1)
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
|
||||
.queryChildren(parent2)
|
||||
|
||||
// Assert the two folders names have changed
|
||||
@@ -175,7 +182,9 @@ class DavDocumentsProviderTest {
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
class TestDispatcher(
|
||||
private val logger: java.util.logging.Logger
|
||||
): Dispatcher() {
|
||||
|
||||
data class Resource(
|
||||
val name: String,
|
||||
@@ -230,8 +239,8 @@ class DavDocumentsProviderTest {
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
Logger.log.info("Query path: $requestPath")
|
||||
Logger.log.info("Response: $multistatus")
|
||||
logger.info("Query path: $requestPath")
|
||||
logger.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(multistatus)
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<resources>
|
||||
|
||||
<string name="app_name">Davx5Test</string>
|
||||
<string name="account_type_test">at.bitfire.davdroid.test</string>
|
||||
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -1,3 +1,5 @@
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="at.bitfire.davdroid.SyncManagerTest"
|
||||
android:accountType="@string/account_type_test"
|
||||
android:icon="@android:drawable/star_on"
|
||||
android:smallIcon="@android:drawable/star_on"
|
||||
android:label="Test Account" />
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
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.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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.syncadapter.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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
6
app/src/debug/res/values/colors.xml
Normal file
6
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primaryColor">#E07C25</color>
|
||||
<color name="primaryLightColor">#E5A371</color>
|
||||
<color name="primaryDarkColor">#7C3E07</color>
|
||||
</resources>
|
||||
4
app/src/debug/res/values/strings.xml
Normal file
4
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
|
||||
</resources>
|
||||
@@ -49,7 +49,7 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
|
||||
android:resizeableActivity="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:supportsRtl="true">
|
||||
@@ -130,7 +130,9 @@
|
||||
<intent-filter>
|
||||
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="http" />
|
||||
<data
|
||||
tools:ignore="AppLinkUrlError"
|
||||
android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
@@ -167,7 +169,7 @@
|
||||
|
||||
<!-- account type "DAVx⁵" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:name=".sync.account.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
@@ -177,7 +179,7 @@
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:name=".sync.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -188,7 +190,7 @@
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.JtxSyncAdapterService"
|
||||
android:name=".sync.JtxSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -199,7 +201,7 @@
|
||||
android:resource="@xml/sync_notes"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.OpenTasksSyncAdapterService"
|
||||
android:name=".sync.OpenTasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -210,7 +212,7 @@
|
||||
android:resource="@xml/sync_opentasks"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.TasksOrgSyncAdapterService"
|
||||
android:name=".sync.TasksOrgSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -233,7 +235,7 @@
|
||||
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.AddressBookAuthenticatorService"
|
||||
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>
|
||||
@@ -244,25 +246,8 @@
|
||||
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=".syncadapter.AddressBookProvider" />
|
||||
<service
|
||||
android:name=".syncadapter.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=".syncadapter.ContactsSyncAdapterService"
|
||||
android:name=".sync.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -288,6 +273,16 @@
|
||||
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">
|
||||
|
||||
4
app/src/main/assets/logging.properties
Normal file
4
app/src/main/assets/logging.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
# reduce verbose of some otherwise annoying ical4j messages
|
||||
net.fortuna.ical4j.data.level = INFO
|
||||
net.fortuna.ical4j.model.Recur.level = INFO
|
||||
@@ -5,81 +5,74 @@
|
||||
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.syncadapter.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.log.LogManager
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
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 java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provider {
|
||||
class App: Application(), Configuration.Provider {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
/**
|
||||
* Creates the [LogManager] singleton and thus initializes logging.
|
||||
*/
|
||||
@Inject
|
||||
lateinit var logManager: LogManager
|
||||
|
||||
@Inject
|
||||
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
||||
|
||||
@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)
|
||||
logger.fine("Logging using LogManager $logManager")
|
||||
|
||||
// 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
|
||||
|
||||
// run startup plugins (sync)
|
||||
for (plugin in plugins.sortedBy { it.priority() }) {
|
||||
logger.fine("Running startup plugin: $plugin (onAppCreate)")
|
||||
plugin.onAppCreate()
|
||||
}
|
||||
|
||||
// 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)
|
||||
AccountsCleanupWorker.enable(this@App)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this@App)
|
||||
|
||||
// run startup plugins (async)
|
||||
for (plugin in plugins.sortedBy { it.priorityAsync() }) {
|
||||
logger.fine("Running startup plugin: $plugin (onAppCreateAsync)")
|
||||
plugin.onAppCreateAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.Collections
|
||||
|
||||
class TextTable(
|
||||
@@ -41,7 +40,7 @@ class TextTable(
|
||||
// first line
|
||||
sb.append("\n┌")
|
||||
for (colIdx in headers.indices)
|
||||
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
|
||||
sb .append("─".repeat(colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┐' else '┬')
|
||||
sb.append('\n')
|
||||
|
||||
@@ -49,14 +48,14 @@ class TextTable(
|
||||
sb.append('│')
|
||||
for (colIdx in headers.indices)
|
||||
sb .append(' ')
|
||||
.append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1))
|
||||
.append(headers[colIdx].padEnd(colWidths[colIdx] + 1))
|
||||
.append('│')
|
||||
sb.append('\n')
|
||||
|
||||
// separator between header and body
|
||||
sb.append('├')
|
||||
for (colIdx in headers.indices) {
|
||||
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
|
||||
sb .append("─".repeat(colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┤' else '┼')
|
||||
}
|
||||
sb.append('\n')
|
||||
@@ -65,7 +64,7 @@ class TextTable(
|
||||
for (line in lines) {
|
||||
for (colIdx in headers.indices)
|
||||
sb .append("│ ")
|
||||
.append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1))
|
||||
.append(line[colIdx].padEnd(colWidths[colIdx] + 1))
|
||||
sb.append("│\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -24,17 +23,16 @@ 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 at.bitfire.davdroid.ui.NotificationRegistry
|
||||
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 java.util.logging.Logger
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Suppress("ClassName")
|
||||
@@ -46,11 +44,12 @@ import javax.inject.Singleton
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 13, autoMigrations = [
|
||||
], exportSchema = true, version = 14, 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)
|
||||
AutoMigration(from = 12, to = 13),
|
||||
AutoMigration(from = 13, to = 14)
|
||||
])
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
@@ -60,29 +59,32 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
object AppDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
fun appDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
notificationRegistry: NotificationRegistry
|
||||
): 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)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
NotificationCompat.Builder(context, notificationRegistry.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()
|
||||
}
|
||||
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccount(account, null, null)
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
@@ -94,7 +96,7 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
@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")
|
||||
Logger.getGlobal().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)
|
||||
@@ -215,7 +217,7 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
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*")
|
||||
Logger.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
|
||||
@@ -22,10 +22,10 @@ import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
@@ -36,6 +36,8 @@ import org.apache.commons.lang3.StringUtils
|
||||
indices = [
|
||||
Index("serviceId","type"),
|
||||
Index("homeSetId","type"),
|
||||
Index("ownerId","type"),
|
||||
Index("pushTopic","type"),
|
||||
Index("url")
|
||||
]
|
||||
)
|
||||
@@ -70,11 +72,28 @@ data class Collection(
|
||||
*/
|
||||
var url: HttpUrl,
|
||||
|
||||
/**
|
||||
* Whether we have the permission to change contents of the collection on the server.
|
||||
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
var privWriteContent: Boolean = true,
|
||||
/**
|
||||
* Whether we have the permission to delete the collection on the server
|
||||
*/
|
||||
var privUnbind: Boolean = true,
|
||||
/**
|
||||
* Whether the user has manually set the "force read-only" flag.
|
||||
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
var forceReadOnly: Boolean = false,
|
||||
|
||||
/**
|
||||
* Human-readable name of the collection
|
||||
*/
|
||||
var displayName: String? = null,
|
||||
/**
|
||||
* Human-readable description of the collection
|
||||
*/
|
||||
var description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
@@ -142,7 +161,7 @@ data class Collection(
|
||||
privUnbind = privilegeSet.mayUnbind
|
||||
}
|
||||
|
||||
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
|
||||
val displayName = dav[DisplayName::class.java]?.displayName.trimToNull()
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
|
||||
@@ -32,6 +32,9 @@ interface CollectionDao {
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
|
||||
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
|
||||
fun getSyncableByPushTopic(topic: String): Collection?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
suspend fun anyOfType(serviceId: Long, type: String): Boolean
|
||||
|
||||
@@ -62,6 +65,16 @@ interface CollectionDao {
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
|
||||
fun getSyncTaskLists(serviceId: Long): List<Collection>
|
||||
|
||||
/**
|
||||
* Get a list of collections that are both sync enabled and push capable (supportsWebPush and
|
||||
* pushTopic is available).
|
||||
*/
|
||||
@Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun getPushCapableSyncCollections(): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync")
|
||||
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(collection: Collection): Long
|
||||
|
||||
@@ -74,6 +87,9 @@ interface CollectionDao {
|
||||
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
|
||||
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
|
||||
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
|
||||
suspend fun updateSync(id: Long, sync: Boolean)
|
||||
|
||||
@@ -94,23 +110,6 @@ interface CollectionDao {
|
||||
localCollection.id
|
||||
} ?: insert(collection)
|
||||
|
||||
/**
|
||||
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
|
||||
* [Collection.forceReadOnly]), but use the values of the already existing collection.
|
||||
*
|
||||
* @param newCollection Collection to be inserted or updated
|
||||
*/
|
||||
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
|
||||
// remember locally set flags
|
||||
getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
|
||||
newCollection.sync = oldCollection.sync
|
||||
newCollection.forceReadOnly = oldCollection.forceReadOnly
|
||||
}
|
||||
|
||||
// commit to database
|
||||
insertOrUpdateByUrl(newCollection)
|
||||
}
|
||||
|
||||
@Delete
|
||||
fun delete(collection: Collection)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "homeset",
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,20 +35,6 @@ interface HomeSetDao {
|
||||
@Update
|
||||
fun update(homeset: HomeSet)
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
|
||||
update(homeset.copy(id = existingHomeset.id))
|
||||
existingHomeset.id
|
||||
} ?: insert(homeset)
|
||||
|
||||
@Delete
|
||||
fun delete(homeset: HomeSet)
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Entity(tableName = "principal",
|
||||
foreignKeys = [
|
||||
@@ -47,9 +47,7 @@ data class Principal(
|
||||
return null
|
||||
|
||||
// Try getting the display name of the principal
|
||||
val displayName: String? = StringUtils.trimToNull(
|
||||
dav[DisplayName::class.java]?.displayName
|
||||
)
|
||||
val displayName: String? = dav[DisplayName::class.java]?.displayName.trimToNull()
|
||||
|
||||
// Create and return principal - even without it's display name
|
||||
return Principal(
|
||||
|
||||
@@ -14,11 +14,11 @@ import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
|
||||
import at.bitfire.davdroid.webdav.DocumentState
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Instant
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "webdav_document",
|
||||
@@ -27,7 +27,8 @@ import java.time.Instant
|
||||
ForeignKey(entity = WebDavDocument::class, parentColumns = ["id"], childColumns = ["parentId"], onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
Index("mountId", "parentId", "name", unique = true)
|
||||
Index("mountId", "parentId", "name", unique = true),
|
||||
Index("parentId")
|
||||
]
|
||||
)
|
||||
data class WebDavDocument(
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
@@ -32,8 +31,12 @@ interface WebDavMountDao {
|
||||
|
||||
// complex queries
|
||||
|
||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
||||
@Transaction
|
||||
fun getAllWithRootDocumentFlow(): Flow<List<WebDavMountWithRootDocument>>
|
||||
/**
|
||||
* Gets a list of mounts with the quotas of their root document, if available.
|
||||
*/
|
||||
@Query("SELECT webdav_mount.*, quotaAvailable, quotaUsed FROM webdav_mount " +
|
||||
"LEFT JOIN webdav_document ON (webdav_mount.id=webdav_document.mountId AND webdav_document.parentId IS NULL) " +
|
||||
"ORDER BY webdav_mount.name, webdav_mount.url")
|
||||
fun getAllWithQuotaFlow(): Flow<List<WebDavMountWithQuota>>
|
||||
|
||||
}
|
||||
@@ -5,18 +5,14 @@
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
/**
|
||||
* A [WebDavMount] with an optional root document (that contains information like quota).
|
||||
*/
|
||||
data class WebDavMountWithRootDocument(
|
||||
data class WebDavMountWithQuota(
|
||||
@Embedded
|
||||
val mount: WebDavMount,
|
||||
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "mountId"
|
||||
)
|
||||
val rootDocument: WebDavDocument?
|
||||
val quotaAvailable: Long? = null,
|
||||
val quotaUsed: Long? = null
|
||||
)
|
||||
172
app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt
Normal file
172
app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Process
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.LogRecord
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Logging handler that logs to a debug log file.
|
||||
*
|
||||
* Shows a permanent notification as long as it's active (until [close] is called).
|
||||
*
|
||||
* Only one [LogFileHandler] should be active at once, because the notification is shared.
|
||||
*/
|
||||
class LogFileHandler @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
): Handler(), Closeable {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DEBUG_INFO_DIRECTORY = "debug"
|
||||
|
||||
/**
|
||||
* 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(context: Context): File? {
|
||||
val dir = File(context.filesDir, DEBUG_INFO_DIRECTORY)
|
||||
if (dir.exists() && dir.isDirectory)
|
||||
return dir
|
||||
|
||||
if (dir.mkdir())
|
||||
return dir
|
||||
|
||||
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(context: Context): File? {
|
||||
val logDir = debugDir(context) ?: return null
|
||||
return File(logDir, "davx5-log.txt")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private var fileHandler: FileHandler? = null
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
private val logFile = getDebugLogFile(context)
|
||||
|
||||
init {
|
||||
if (logFile != null) {
|
||||
if (logFile.createNewFile())
|
||||
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
|
||||
|
||||
// actual logging is handled by a FileHandler
|
||||
fileHandler = FileHandler(logFile.toString(), true).apply {
|
||||
formatter = PlainTextFormatter.DEFAULT
|
||||
}
|
||||
|
||||
showNotification()
|
||||
} else {
|
||||
logger.severe("Couldn't create log file in app-private directory $DEBUG_INFO_DIRECTORY/.")
|
||||
level = Level.OFF
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Synchronized
|
||||
override fun publish(record: LogRecord) {
|
||||
fileHandler?.publish(record)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun flush() {
|
||||
fileHandler?.flush()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
fileHandler?.close()
|
||||
fileHandler = null
|
||||
|
||||
// remove all files in debug info directory, may also contain zip files from debug info activity etc.
|
||||
logFile?.parentFile?.deleteRecursively()
|
||||
|
||||
removeNotification()
|
||||
}
|
||||
|
||||
|
||||
// notifications
|
||||
|
||||
private fun showNotification() {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_VERBOSE_LOGGING) {
|
||||
val builder = NotificationCompat.Builder(context, notificationRegistry.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)
|
||||
|
||||
// add action to view/share the logs
|
||||
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()
|
||||
)
|
||||
|
||||
// add action to disable verbose logging
|
||||
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()
|
||||
)
|
||||
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeNotification() {
|
||||
notificationManager.cancel(NotificationRegistry.NOTIFY_VERBOSE_LOGGING)
|
||||
}
|
||||
|
||||
}
|
||||
89
app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt
Normal file
89
app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.repository.PreferenceRepository
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Handles logging configuration and which loggers are active at a moment.
|
||||
* To initialize, just make sure that the [LogManager] singleton is created.
|
||||
*
|
||||
* Configures the root logger like this:
|
||||
*
|
||||
* - Always logs to logcat.
|
||||
* - Watches the "log to file" preference and activates or deactivates file logging accordingly.
|
||||
* - If "log to file" is enabled, log level is set to [Level.ALL].
|
||||
* - Otherwise, log level is set to [Level.INFO].
|
||||
*
|
||||
* Preferred ways to get a [Logger] are:
|
||||
*
|
||||
* - `@Inject` [Logger] for a general-purpose logger when injection is possible
|
||||
* - `Logger.getGlobal()` for a general-purpose logger
|
||||
* - `Logger.getLogger(javaClass.name)` for a specific logger that can be customized
|
||||
*
|
||||
* When using the global logger, the class name of the logging calls will still be logged, so there's
|
||||
* no need to always get a separate logger for each class (only if the class wants to customize it).
|
||||
*/
|
||||
@Singleton
|
||||
class LogManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logFileHandler: Provider<LogFileHandler>,
|
||||
private val logger: Logger,
|
||||
private val prefs: PreferenceRepository
|
||||
) : AutoCloseable {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
init {
|
||||
// observe preference changes
|
||||
scope.launch {
|
||||
prefs.logToFileFlow().collect {
|
||||
reloadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
reloadConfig()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reloadConfig() {
|
||||
val logToFile = prefs.logToFile()
|
||||
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(logger.name, Log.DEBUG)
|
||||
logger.info("Verbose logging = $logVerbose; log to file = $logToFile")
|
||||
|
||||
// reset existing loggers and initialize from assets/logging.properties
|
||||
context.assets.open("logging.properties").use {
|
||||
val javaLogManager = java.util.logging.LogManager.getLogManager()
|
||||
javaLogManager.readConfiguration(it)
|
||||
}
|
||||
|
||||
// root logger: set default log level and always log to logcat
|
||||
val rootLogger = Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
rootLogger.addHandler(LogcatHandler())
|
||||
|
||||
// log to file, if requested
|
||||
if (logToFile)
|
||||
rootLogger.addHandler(logFileHandler.get())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,43 +4,49 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import com.google.common.base.Ascii
|
||||
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
|
||||
/**
|
||||
* Logging handler that logs to Android logcat.
|
||||
*/
|
||||
internal class LogcatHandler: Handler() {
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
level = Level.ALL
|
||||
}
|
||||
|
||||
override fun publish(r: LogRecord) {
|
||||
val text = formatter.format(r)
|
||||
val level = r.level.intValue()
|
||||
val text = formatter.format(r)
|
||||
|
||||
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
|
||||
// get class name that calls the logger (or fall back to package name)
|
||||
val className = if (r.sourceClassName != null)
|
||||
PlainTextFormatter.shortClassName(r.sourceClassName)
|
||||
else
|
||||
BuildConfig.APPLICATION_ID
|
||||
|
||||
// truncate class name to 23 characters on Android <8, see Log documentation
|
||||
val tag = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
Ascii.truncate(className, 23, "")
|
||||
else
|
||||
className
|
||||
|
||||
when {
|
||||
level >= Level.SEVERE.intValue() -> Log.e(tag, text, r.thrown)
|
||||
level >= Level.WARNING.intValue() -> Log.w(tag, text, r.thrown)
|
||||
level >= Level.CONFIG.intValue() -> Log.i(tag, text, r.thrown)
|
||||
level >= Level.FINER.intValue() -> Log.d(tag, text, r.thrown)
|
||||
else -> Log.v(tag, text, r.thrown)
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
20
app/src/main/kotlin/at/bitfire/davdroid/log/LoggerModule.kt
Normal file
20
app/src/main/kotlin/at/bitfire/davdroid/log/LoggerModule.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.logging.Logger
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class LoggerModule {
|
||||
|
||||
@Provides
|
||||
fun globalLogger(): Logger = Logger.getGlobal()
|
||||
|
||||
}
|
||||
@@ -4,55 +4,108 @@
|
||||
|
||||
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 com.google.common.base.Ascii
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.logging.Formatter
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class PlainTextFormatter private constructor(
|
||||
private val logcat: Boolean
|
||||
class PlainTextFormatter(
|
||||
private val withTime: Boolean,
|
||||
private val withSource: Boolean,
|
||||
private val padSource: Int = 30,
|
||||
private val withException: Boolean,
|
||||
private val lineSeparator: String?
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
val LOGCAT = PlainTextFormatter(true)
|
||||
val DEFAULT = PlainTextFormatter(false)
|
||||
|
||||
const val MAX_MESSAGE_LENGTH = 20000
|
||||
/**
|
||||
* Formatter intended for logcat output.
|
||||
*/
|
||||
val LOGCAT = PlainTextFormatter(
|
||||
withTime = false,
|
||||
withSource = false,
|
||||
withException = false,
|
||||
lineSeparator = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Formatter intended for file output.
|
||||
*/
|
||||
val DEFAULT = PlainTextFormatter(
|
||||
withTime = true,
|
||||
withSource = true,
|
||||
withException = true,
|
||||
lineSeparator = System.lineSeparator()
|
||||
)
|
||||
|
||||
/**
|
||||
* Maximum length of a log line (estimate).
|
||||
*/
|
||||
const val MAX_LENGTH = 10000
|
||||
|
||||
fun shortClassName(className: String) = className
|
||||
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), ".")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
|
||||
private fun stackTrace(ex: Throwable): String {
|
||||
val writer = StringWriter()
|
||||
ex.printStackTrace(PrintWriter(writer))
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
|
||||
|
||||
|
||||
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))
|
||||
if (withTime)
|
||||
builder .append(timeFormat.format(Date(r.millis)))
|
||||
.append(" ").append(r.threadID).append(" ")
|
||||
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
if (className != r.loggerName)
|
||||
builder.append("[").append(className).append("] ")
|
||||
if (withSource && r.sourceClassName != null) {
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
if (className != r.loggerName) {
|
||||
val classNameColumn = "[$className] ".padEnd(padSource)
|
||||
builder.append(classNameColumn)
|
||||
}
|
||||
}
|
||||
|
||||
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
|
||||
builder.append(truncate(r.message))
|
||||
|
||||
r.thrown?.let {
|
||||
builder .append("\nEXCEPTION ")
|
||||
.append(ExceptionUtils.getStackTrace(it))
|
||||
if (withException && r.thrown != null) {
|
||||
val indentedStackTrace = stackTrace(r.thrown)
|
||||
.replace("\n", "\n\t")
|
||||
.removeSuffix("\t")
|
||||
builder.append("\n\tEXCEPTION ").append(indentedStackTrace)
|
||||
}
|
||||
|
||||
r.parameters?.let {
|
||||
for ((idx, param) in it.withIndex())
|
||||
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
|
||||
for ((idx, param) in it.withIndex()) {
|
||||
builder.append("\n\tPARAMETER #").append(idx + 1).append(" = ")
|
||||
|
||||
val valStr = if (param == null)
|
||||
"(null)"
|
||||
else
|
||||
truncate(param.toString())
|
||||
builder.append(valStr)
|
||||
}
|
||||
}
|
||||
|
||||
if (!logcat)
|
||||
builder.append("\n")
|
||||
if (lineSeparator != null)
|
||||
builder.append(lineSeparator)
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun shortClassName(className: String) = className
|
||||
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
private fun truncate(s: String) =
|
||||
Ascii.truncate(s, MAX_LENGTH, "[…]")
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,22 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import com.google.common.base.Ascii
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class StringHandler: Handler() {
|
||||
/**
|
||||
* Handler that writes log messages to a string buffer.
|
||||
*
|
||||
* @param maxSize Maximum size of the buffer. If the buffer exceeds this size, it will be truncated.
|
||||
*/
|
||||
class StringHandler(
|
||||
private val maxSize: Int
|
||||
): Handler() {
|
||||
|
||||
companion object {
|
||||
const val TRUNCATION_MARKER = "[...]"
|
||||
}
|
||||
|
||||
val builder = StringBuilder()
|
||||
|
||||
@@ -16,7 +28,24 @@ class StringHandler: Handler() {
|
||||
}
|
||||
|
||||
override fun publish(record: LogRecord) {
|
||||
builder.append(formatter.format(record))
|
||||
var text = formatter.format(record)
|
||||
|
||||
val currentSize = builder.length
|
||||
val sizeLeft = maxSize - currentSize
|
||||
|
||||
when {
|
||||
// Append the text if there is enough space
|
||||
sizeLeft > text.length ->
|
||||
builder.append(text)
|
||||
|
||||
// Truncate the text if there is not enough space
|
||||
sizeLeft > TRUNCATION_MARKER.length -> {
|
||||
text = Ascii.truncate(text, maxSize - currentSize, TRUNCATION_MARKER)
|
||||
builder.append(text)
|
||||
}
|
||||
|
||||
// Do nothing if the buffer is already full
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
@@ -24,4 +53,4 @@ class StringHandler: Handler() {
|
||||
|
||||
override fun toString() = builder.toString()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,23 @@ import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.xbill.DNS.EDNSOption
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Resolver
|
||||
import org.xbill.DNS.ResolverListener
|
||||
import org.xbill.DNS.TSIG
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* dnsjava Resolver that uses Android's [DnsResolver] API, which is available since Android 10.
|
||||
* dnsjava [Resolver] that uses Android's [DnsResolver] API, which can resolve raw queries and
|
||||
* is available since Android 10.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
object Android10Resolver: Resolver {
|
||||
class Android10Resolver : Resolver {
|
||||
|
||||
private val executor = Dispatchers.IO.asExecutor()
|
||||
private val resolver = DnsResolver.getInstance()
|
||||
|
||||
|
||||
override fun send(query: Message): Message = runBlocking {
|
||||
val future = CompletableDeferred<Message>()
|
||||
|
||||
@@ -44,10 +45,6 @@ object Android10Resolver: Resolver {
|
||||
future.await()
|
||||
}
|
||||
|
||||
override fun sendAsync(query: Message, listener: ResolverListener) =
|
||||
// currently not used by dnsjava, so no need to implement it
|
||||
throw NotImplementedError()
|
||||
|
||||
|
||||
override fun setPort(port: Int) {
|
||||
// not applicable
|
||||
@@ -61,11 +58,7 @@ object Android10Resolver: Resolver {
|
||||
// not applicable
|
||||
}
|
||||
|
||||
override fun setEDNS(level: Int) {
|
||||
// not applicable
|
||||
}
|
||||
|
||||
override fun setEDNS(level: Int, payloadSize: Int, flags: Int, options: MutableList<Any?>?) {
|
||||
override fun setEDNS(version: Int, payloadSize: Int, flags: Int, options: MutableList<EDNSOption>?) {
|
||||
// not applicable
|
||||
}
|
||||
|
||||
@@ -73,11 +66,7 @@ object Android10Resolver: Resolver {
|
||||
// not applicable
|
||||
}
|
||||
|
||||
override fun setTimeout(secs: Int, msecs: Int) {
|
||||
// not applicable
|
||||
}
|
||||
|
||||
override fun setTimeout(secs: Int) {
|
||||
override fun setTimeout(timeout: Duration?) {
|
||||
// not applicable
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -14,6 +13,7 @@ import net.openid.appauth.AuthorizationService
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Sends an OAuth Bearer token authorization as described in RFC 6750.
|
||||
@@ -24,6 +24,9 @@ class BearerAuthInterceptor(
|
||||
|
||||
companion object {
|
||||
|
||||
val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
|
||||
return runBlocking {
|
||||
val accessTokenFuture = CompletableDeferred<String>()
|
||||
@@ -37,7 +40,7 @@ class BearerAuthInterceptor(
|
||||
accessTokenFuture.complete(accessToken)
|
||||
}
|
||||
else {
|
||||
Logger.log.log(Level.WARNING, "Couldn't obtain access token", ex)
|
||||
logger.log(Level.WARNING, "Couldn't obtain access token", ex)
|
||||
accessTokenFuture.cancel()
|
||||
}
|
||||
}
|
||||
@@ -54,7 +57,7 @@ class BearerAuthInterceptor(
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
Logger.log.finer("Authenticating request with access token")
|
||||
logger.finer("Authenticating request with access token")
|
||||
val rq = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.Resolver
|
||||
import org.xbill.DNS.ResolverConfig
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.SimpleResolver
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import java.net.InetAddress
|
||||
import java.util.LinkedList
|
||||
import java.util.TreeMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Allows to resolve SRV/TXT records. Chooses the correct resolver, DNS servers etc.
|
||||
*/
|
||||
class DnsRecordResolver @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
// resolving
|
||||
|
||||
/**
|
||||
* Fallback DNS server that will be used when other DNS are not known or working.
|
||||
* `9.9.9.9` belongs to Cloudflare who promise good privacy.
|
||||
*/
|
||||
private val DNS_FALLBACK = InetAddress.getByAddress(byteArrayOf(9,9,9,9))
|
||||
|
||||
private val resolver by lazy { chooseResolver() }
|
||||
|
||||
init {
|
||||
// empty initialization for dnsjava because we set the servers for each request
|
||||
ResolverConfig.setConfigProviders(listOf())
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a matching Resolver, depending on the Android version:
|
||||
*
|
||||
* Android 10+: Android10Resolver, which uses the raw DNS resolver that comes with Android
|
||||
* Android <10: ExtendedResolver, which uses the known DNS servers to resolve DNS queries
|
||||
*/
|
||||
private fun chooseResolver(): Resolver =
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
/* Since Android 10, there's a native DnsResolver API that allows to send SRV queries without
|
||||
knowing which DNS servers have to be used. DNS over TLS is now also supported. */
|
||||
logger.fine("Using Android 10+ DnsResolver")
|
||||
Android10Resolver()
|
||||
|
||||
} else {
|
||||
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
|
||||
The current version of dnsjava relies on these properties to find the default name servers,
|
||||
so we have to add the servers explicitly (fortunately, there's an Android API to
|
||||
get the DNS servers of the network connections). */
|
||||
val dnsServers = LinkedList<InetAddress>()
|
||||
|
||||
val connectivity = context.getSystemService<ConnectivityManager>()!!
|
||||
@Suppress("DEPRECATION")
|
||||
connectivity.allNetworks.forEach { network ->
|
||||
val active = connectivity.getNetworkInfo(network)?.isConnected == true
|
||||
connectivity.getLinkProperties(network)?.let { link ->
|
||||
if (active)
|
||||
// active connection, insert at top of list
|
||||
dnsServers.addAll(0, link.dnsServers)
|
||||
else
|
||||
// inactive connection, insert at end of list
|
||||
dnsServers.addAll(link.dnsServers)
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: add Quad9 DNS in case that no other DNS works
|
||||
dnsServers.add(DNS_FALLBACK)
|
||||
|
||||
val uniqueDnsServers = LinkedHashSet<InetAddress>(dnsServers)
|
||||
val simpleResolvers = uniqueDnsServers.map { dns ->
|
||||
logger.fine("Adding DNS server ${dns.hostAddress}")
|
||||
SimpleResolver(dns)
|
||||
}
|
||||
|
||||
// combine SimpleResolvers which query one DNS server each to an ExtendedResolver
|
||||
ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
}
|
||||
|
||||
fun resolve(query: String, type: Int): Array<out Record> {
|
||||
val lookup = Lookup(query, type)
|
||||
lookup.setResolver(resolver)
|
||||
return lookup.run().orEmpty()
|
||||
}
|
||||
|
||||
|
||||
// record selection
|
||||
|
||||
fun bestSRVRecord(records: Array<out Record>): SRVRecord? {
|
||||
val srvRecords = records.filterIsInstance<SRVRecord>()
|
||||
if (srvRecords.size <= 1)
|
||||
return srvRecords.firstOrNull()
|
||||
|
||||
/* RFC 2782
|
||||
Priority
|
||||
The priority of this target host. A client MUST attempt to
|
||||
contact the target host with the lowest-numbered priority it can
|
||||
reach; target hosts with the same priority SHOULD be tried in an
|
||||
order defined by the weight field. [...]
|
||||
Weight
|
||||
A server selection mechanism. The weight field specifies a
|
||||
relative weight for entries with the same priority. [...]
|
||||
To select a target to be contacted next, arrange all SRV RRs
|
||||
(that have not been ordered yet) in any order, except that all
|
||||
those with weight 0 are placed at the beginning of the list.
|
||||
Compute the sum of the weights of those RRs, and with each RR
|
||||
associate the running sum in the selected order. Then choose a
|
||||
uniform random number between 0 and the sum computed
|
||||
(inclusive), and select the RR whose running sum value is the
|
||||
first in the selected order which is greater than or equal to
|
||||
the random number selected. The target host specified in the
|
||||
selected SRV RR is the next one to be contacted by the client.
|
||||
*/
|
||||
|
||||
// Select records which have the minimum priority
|
||||
val minPriority = srvRecords.minOfOrNull { it.priority }
|
||||
val usableRecords = srvRecords.filter { it.priority == minPriority }
|
||||
.sortedBy { it.weight != 0 } // and put those with weight 0 first
|
||||
|
||||
val map = TreeMap<Int, SRVRecord>()
|
||||
var runningWeight = 0
|
||||
for (record in usableRecords) {
|
||||
val weight = record.weight
|
||||
runningWeight += weight
|
||||
map[runningWeight] = record
|
||||
}
|
||||
|
||||
val selector = (0..runningWeight).random()
|
||||
return map.ceilingEntry(selector)!!.value
|
||||
}
|
||||
|
||||
fun pathsFromTXTRecords(records: Array<out Record>): List<String> {
|
||||
val paths = LinkedList<String>()
|
||||
records.filterIsInstance<TXTRecord>().forEach { txt ->
|
||||
for (segment in txt.strings as List<String>)
|
||||
if (segment.startsWith("path="))
|
||||
paths.add(segment.substring(5))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -20,10 +19,14 @@ import net.openid.appauth.AuthorizationServiceConfiguration
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import net.openid.appauth.TokenResponse
|
||||
import java.net.URI
|
||||
import java.util.logging.Logger
|
||||
|
||||
class GoogleLogin(
|
||||
val authService: AuthorizationService
|
||||
) {
|
||||
|
||||
private val logger: Logger = Logger.getGlobal()
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -72,7 +75,7 @@ class GoogleLogin(
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
|
||||
Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
|
||||
logger.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
|
||||
|
||||
if (tokenResponse != null) {
|
||||
// success, save authState (= refresh token)
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -23,6 +22,21 @@ import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationService
|
||||
@@ -37,22 +51,6 @@ import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import 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,
|
||||
@@ -108,7 +106,7 @@ class HttpClient @AssistedInject constructor(
|
||||
class Builder(
|
||||
val context: Context,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: java.util.logging.Logger? = Logger.log,
|
||||
val logger: Logger = Logger.getGlobal(),
|
||||
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
) {
|
||||
|
||||
@@ -116,7 +114,7 @@ class HttpClient @AssistedInject constructor(
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface HttpClientBuilderEntryPoint {
|
||||
fun authorizationService(): AuthorizationService
|
||||
fun httpClientFactory(): HttpClient.Factory
|
||||
fun httpClientFactory(): Factory
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
@@ -140,7 +138,7 @@ class HttpClient @AssistedInject constructor(
|
||||
|
||||
init {
|
||||
// add network logging, if requested
|
||||
if (logger != null && logger.isLoggable(Level.FINEST)) {
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.level = loggerLevel
|
||||
orig.addNetworkInterceptor(loggingInterceptor)
|
||||
@@ -167,10 +165,10 @@ class HttpClient @AssistedInject constructor(
|
||||
else -> throw IllegalArgumentException("Invalid proxy type")
|
||||
}
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy setting", proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
customCertManager {
|
||||
@@ -239,7 +237,7 @@ class HttpClient @AssistedInject constructor(
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
Logger.log.fine("Using disk cache: $cacheDir")
|
||||
logger.fine("Using disk cache: $cacheDir")
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
@@ -319,14 +317,11 @@ class HttpClient @AssistedInject constructor(
|
||||
|
||||
object UserAgentInterceptor: Interceptor {
|
||||
|
||||
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests")
|
||||
Logger.getGlobal().info("Will set User-Agent: $userAgent")
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
@@ -340,4 +335,4 @@ class HttpClient @AssistedInject constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,54 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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 {
|
||||
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>())!!
|
||||
data class StorageKey(
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
private val storage = mutableMapOf<StorageKey, Cookie>()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
/* [RFC 6265 5.3 Storage Model]
|
||||
|
||||
11. If the cookie store contains a cookie with the same name,
|
||||
domain, and path as the newly created cookie:
|
||||
|
||||
1. Let old-cookie be the existing cookie with the same name,
|
||||
domain, and path as the newly created cookie. (Notice that
|
||||
this algorithm maintains the invariant that there is at most
|
||||
one such cookie.)
|
||||
|
||||
2. If the newly created cookie was received from a "non-HTTP"
|
||||
API and the old-cookie's http-only-flag is set, abort these
|
||||
steps and ignore the newly created cookie entirely.
|
||||
|
||||
3. Update the creation-time of the newly created cookie to
|
||||
match the creation-time of the old-cookie.
|
||||
|
||||
4. Remove the old-cookie from the cookie store.
|
||||
*/
|
||||
synchronized(storage) {
|
||||
for (cookie in cookies)
|
||||
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
|
||||
storage.putAll(cookies.map {
|
||||
StorageKey(
|
||||
domain = it.domain,
|
||||
path = it.path,
|
||||
name = it.name
|
||||
) to it
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +59,9 @@ class MemoryCookieStore: CookieJar {
|
||||
val cookies = LinkedList<Cookie>()
|
||||
|
||||
synchronized(storage) {
|
||||
val iter = storage.mapIterator()
|
||||
val iter = storage.iterator()
|
||||
while (iter.hasNext()) {
|
||||
iter.next()
|
||||
val cookie = iter.value
|
||||
val (_, cookie) = iter.next()
|
||||
|
||||
// remove expired cookies
|
||||
if (cookie.expiresAt <= System.currentTimeMillis()) {
|
||||
@@ -47,7 +69,7 @@ class MemoryCookieStore: CookieJar {
|
||||
continue
|
||||
}
|
||||
|
||||
// add applicable cookies
|
||||
// add applicable cookies to result
|
||||
if (cookie.matches(url))
|
||||
cookies += cookie
|
||||
}
|
||||
@@ -56,4 +78,4 @@ class MemoryCookieStore: CookieJar {
|
||||
return cookies
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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.davdroid.util.withTrailingSlash
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
@@ -20,7 +21,6 @@ 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
|
||||
@@ -101,7 +101,7 @@ class NextcloudLoginFlow(
|
||||
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"), "/")
|
||||
val serverUrl = json.getString("server").withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import at.bitfire.dav4jvm.XmlReader
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.property.push.PushMessage
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.StringReader
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushMessageParser @Inject constructor(
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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))
|
||||
|
||||
XmlReader(parser).processTag(PushMessage.NAME) {
|
||||
val pushMessage = PushMessage.Factory.create(parser)
|
||||
topic = pushMessage.topic
|
||||
}
|
||||
} catch (e: XmlPullParserException) {
|
||||
logger.log(Level.WARNING, "Couldn't parse push message", e)
|
||||
}
|
||||
|
||||
return topic
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushNotificationManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
) {
|
||||
|
||||
/**
|
||||
* Generates the notification ID for a push notification.
|
||||
*/
|
||||
private fun notificationId(account: Account, authority: String): Int {
|
||||
return account.name.hashCode() + account.type.hashCode() + authority.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification to inform the user that a push notification has been received, the
|
||||
* sync has been scheduled, but it still has not run.
|
||||
*/
|
||||
fun notify(account: Account, authority: String) {
|
||||
notificationRegistry.notifyIfPossible(notificationId(account, authority)) {
|
||||
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_sync)
|
||||
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
|
||||
.setContentText(context.getString(R.string.sync_notification_pending_push_message))
|
||||
.setSubText(account.name)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, AccountActivity::class.java).apply {
|
||||
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the sync has been started, the notification is no longer needed and can be dismissed.
|
||||
* It's safe to call this method even if the notification has not been shown.
|
||||
*/
|
||||
fun dismiss(account: Account, authority: String) {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationId(account, authority))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.NS_WEBDAV_PUSH
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
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.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.StringWriter
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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 (to refresh registrations that are about to expire).
|
||||
* Not required for a first demonstration version.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
@HiltWorker
|
||||
class PushRegistrationWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted workerParameters: WorkerParameters,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val logger: Logger,
|
||||
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.getGlobal().info("Enqueueing push registration worker")
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
logger.info("Running push registration worker")
|
||||
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun registerPushSubscription(collection: Collection, account: Account, endpoint: String) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, settings)
|
||||
.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) {
|
||||
response.header("Location")?.let { subscriptionUrl ->
|
||||
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
|
||||
}
|
||||
} else
|
||||
logger.warning("Couldn't register push for ${collection.url}: $response")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerSyncable() {
|
||||
val endpoint = preferenceRepository.unifiedPushEndpoint()
|
||||
|
||||
// register push subscription for syncable collections
|
||||
if (endpoint != null)
|
||||
for (collection in collectionRepository.getPushCapableAndSyncable()) {
|
||||
logger.info("Registering push for ${collection.url}")
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
try {
|
||||
registerPushSubscription(collection, account, endpoint)
|
||||
} catch (e: DavException) {
|
||||
// catch possible per-collection exception so that all collections can be processed
|
||||
logger.log(Level.WARNING, "Couldn't register push for ${collection.url}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
logger.info("No UnifiedPush endpoint configured")
|
||||
}
|
||||
|
||||
private suspend fun unregisterPushSubscription(collection: Collection, account: Account, url: HttpUrl) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, settings)
|
||||
.setForeground(true)
|
||||
.build()
|
||||
.use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
try {
|
||||
DavResource(httpClient, url).delete {
|
||||
// deleted
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
|
||||
}
|
||||
|
||||
// remove registration URL from DB in any case
|
||||
collectionRepository.updatePushSubscription(collection.id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unregisterNotSyncable() {
|
||||
for (collection in collectionRepository.getPushRegisteredAndNotSyncable()) {
|
||||
logger.info("Unregistering push for ${collection.url}")
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
unregisterPushSubscription(collection, account, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.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.SyncWorkerManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
|
||||
@AndroidEntryPoint
|
||||
class UnifiedPushReceiver: MessagingReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var preferenceRepository: PreferenceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var parsePushMessage: PushMessageParser
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
|
||||
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) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val messageXml = message.toString(Charsets.UTF_8)
|
||||
logger.log(Level.INFO, "Received push message", messageXml)
|
||||
|
||||
// parse push notification
|
||||
val topic = parsePushMessage(messageXml)
|
||||
|
||||
// sync affected collection
|
||||
if (topic != null) {
|
||||
logger.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)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.warning("Got push message without topic, syncing all accounts")
|
||||
for (account in accountRepository.getAll())
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,23 +4,17 @@
|
||||
|
||||
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.content.Context
|
||||
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.AppDatabase
|
||||
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
|
||||
@@ -28,17 +22,20 @@ 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.syncadapter.AccountUtils
|
||||
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
|
||||
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.util.TaskUtils
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -48,17 +45,22 @@ import javax.inject.Inject
|
||||
* [at.bitfire.davdroid.resource.LocalAddressBook].
|
||||
*/
|
||||
class AccountRepository @Inject constructor(
|
||||
val context: Application,
|
||||
val db: AppDatabase,
|
||||
val settingsManager: SettingsManager,
|
||||
val serviceRepository: DavServiceRepository
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val logger: Logger,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
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
|
||||
* Creates a new account with discovered services and enables periodic syncs with
|
||||
* default sync interval times.
|
||||
*
|
||||
* @param accountName name of the account
|
||||
@@ -69,19 +71,19 @@ class AccountRepository @Inject constructor(
|
||||
* @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 = account(accountName)
|
||||
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))
|
||||
logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
|
||||
|
||||
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
|
||||
return null
|
||||
|
||||
// add entries for account to service DB
|
||||
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
|
||||
logger.log(Level.INFO, "Writing account configuration to database", config)
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
|
||||
|
||||
// Configure CardDAV service
|
||||
@@ -90,68 +92,64 @@ class AccountRepository @Inject constructor(
|
||||
// insert CardDAV service
|
||||
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
|
||||
|
||||
// initial CardDAV account settings
|
||||
// initial CardDAV account settings and sync intervals
|
||||
accountSettings.setGroupMethod(groupMethod)
|
||||
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
|
||||
|
||||
// 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)
|
||||
val taskProvider = tasksAppManager.get().currentProvider()
|
||||
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.")
|
||||
logger.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
|
||||
} else
|
||||
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
|
||||
logger.info("No tasks provider found. Did not enable tasks sync.")
|
||||
|
||||
// start CalDAV service detection (refresh collections)
|
||||
RefreshCollectionsWorker.enqueue(context, id)
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
|
||||
logger.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(account(accountName), null, null, null)
|
||||
val account = fromName(accountName)
|
||||
// remove account directly (bypassing the authenticator, which is our own)
|
||||
return try {
|
||||
// wait for operation to complete
|
||||
withContext(Dispatchers.Default) {
|
||||
// blocks calling thread
|
||||
future.result
|
||||
}
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
|
||||
// delete address book accounts
|
||||
LocalAddressBook.deleteByAccount(context, accountName)
|
||||
// delete address books (= address book accounts)
|
||||
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
|
||||
collectionRepository.getByService(service.id).forEach { collection ->
|
||||
LocalAddressBook.deleteByCollection(context, collection.id)
|
||||
}
|
||||
}
|
||||
|
||||
// delete from database
|
||||
serviceRepository.deleteByAccount(accountName)
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't remove account $accountName", e)
|
||||
logger.log(Level.WARNING, "Couldn't remove account $accountName", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -162,7 +160,10 @@ class AccountRepository @Inject constructor(
|
||||
else
|
||||
accountManager
|
||||
.getAccountsByType(accountType)
|
||||
.contains(Account(accountName, accountType))
|
||||
.any { it.name == accountName }
|
||||
|
||||
fun fromName(accountName: String) =
|
||||
Account(accountName, accountType)
|
||||
|
||||
fun getAll(): Array<Account> = accountManager.getAccountsByType(accountType)
|
||||
|
||||
@@ -170,7 +171,9 @@ class AccountRepository @Inject constructor(
|
||||
val listener = OnAccountsUpdateListener { accounts ->
|
||||
trySend(accounts.filter { it.type == accountType }.toSet())
|
||||
}
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||
withContext(Dispatchers.Default) { // causes disk I/O
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
accountManager.removeOnAccountsUpdatedListener(listener)
|
||||
@@ -191,20 +194,20 @@ class AccountRepository @Inject constructor(
|
||||
* @throws Exception (or sub-classes) on other errors
|
||||
*/
|
||||
suspend fun rename(oldName: String, newName: String) {
|
||||
val oldAccount = account(oldName)
|
||||
val newAccount = account(newName)
|
||||
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 oldSettings = accountSettingsFactory.create(oldAccount)
|
||||
val authorities = mutableListOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY
|
||||
)
|
||||
val tasksProvider = TaskUtils.currentProvider(context)
|
||||
val tasksProvider = tasksAppManager.get().currentProvider()
|
||||
tasksProvider?.authority?.let { authorities.add(it) }
|
||||
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
|
||||
|
||||
@@ -235,27 +238,12 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
// disable periodic syncs for old account
|
||||
syncIntervals.forEach { (authority, _) ->
|
||||
PeriodicSyncWorker.disable(context, oldAccount, authority)
|
||||
syncWorkerManager.disablePeriodic(oldAccount, authority)
|
||||
}
|
||||
|
||||
// update account name references in database
|
||||
serviceRepository.renameAccount(oldName, newName)
|
||||
|
||||
// update main account of address book accounts
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
|
||||
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
val addressBook = LocalAddressBook(context, addrBookAccount, provider)
|
||||
if (oldAccount == addressBook.mainAccount)
|
||||
addressBook.mainAccount = Account(newName, oldAccount.type)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.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)
|
||||
|
||||
@@ -263,12 +251,12 @@ class AccountRepository @Inject constructor(
|
||||
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)
|
||||
logger.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)
|
||||
val newSettings = accountSettingsFactory.create(newAccount)
|
||||
for ((authority, interval) in syncIntervals) {
|
||||
if (interval == null)
|
||||
ContentResolver.setIsSyncable(newAccount, authority, 0)
|
||||
@@ -286,23 +274,19 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
// helpers
|
||||
|
||||
private fun account(accountName: String) = Account(accountName, accountType)
|
||||
|
||||
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
|
||||
// insert service
|
||||
val service = Service(0, accountName, type, info.principal)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
val serviceId = serviceRepository.insertOrReplace(service)
|
||||
|
||||
// insert home sets
|
||||
val homeSetDao = db.homeSetDao()
|
||||
for (homeSet in info.homeSets)
|
||||
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
|
||||
homeSetRepository.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
|
||||
|
||||
// insert collections
|
||||
val collectionDao = db.collectionDao()
|
||||
for (collection in info.collections.values) {
|
||||
collection.serviceId = serviceId
|
||||
collectionDao.insertOrUpdateByUrl(collection)
|
||||
collectionRepository.insertOrUpdateByUrl(collection)
|
||||
}
|
||||
|
||||
return serviceId
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
@@ -28,26 +29,44 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
import java.io.StringWriter
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.fortuna.ical4j.model.Calendar
|
||||
import net.fortuna.ical4j.model.Component
|
||||
import net.fortuna.ical4j.model.ComponentList
|
||||
import net.fortuna.ical4j.model.component.VTimeZone
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.StringWriter
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Repository for managing collections.
|
||||
*
|
||||
* Implements an observer pattern that can be used to listen for changes of collections.
|
||||
*/
|
||||
class DavCollectionRepository @Inject constructor(
|
||||
val context: Application,
|
||||
db: AppDatabase
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
db: AppDatabase,
|
||||
defaultListeners: Set<@JvmSuppressWildcards OnChangeListener>,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
private val listeners = Collections.synchronizedSet(defaultListeners.toMutableSet())
|
||||
|
||||
private val dao = db.collectionDao()
|
||||
|
||||
suspend fun anyWebcal(serviceId: Long) =
|
||||
dao.anyOfType(serviceId, Collection.TYPE_WEBCAL)
|
||||
|
||||
/**
|
||||
* Creates address book collection on server and locally
|
||||
*/
|
||||
suspend fun createAddressBook(
|
||||
account: Account,
|
||||
homeSet: HomeSet,
|
||||
@@ -77,13 +96,18 @@ class DavCollectionRepository @Inject constructor(
|
||||
serviceId = homeSet.serviceId,
|
||||
homeSetId = homeSet.id,
|
||||
url = url,
|
||||
type = Collection.TYPE_ADDRESSBOOK, //if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
displayName = displayName,
|
||||
description = description
|
||||
)
|
||||
dao.insertAsync(collection)
|
||||
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create calendar collection on server and locally
|
||||
*/
|
||||
suspend fun createCalendar(
|
||||
account: Account,
|
||||
homeSet: HomeSet,
|
||||
@@ -111,7 +135,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezoneDef = timeZoneId,
|
||||
timezoneId = timeZoneId,
|
||||
supportsVEVENT = supportVEVENT,
|
||||
supportsVTODO = supportVTODO,
|
||||
supportsVJOURNAL = supportVJOURNAL
|
||||
@@ -127,7 +151,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timeZoneId?.let { getVTimeZone(it) },
|
||||
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
|
||||
supportsVEVENT = supportVEVENT,
|
||||
supportsVTODO = supportVTODO,
|
||||
supportsVJOURNAL = supportVJOURNAL
|
||||
@@ -137,42 +161,118 @@ class DavCollectionRepository @Inject constructor(
|
||||
// Trigger service detection (because the collection may actually have other properties than the ones we have inserted).
|
||||
// Some servers are known to change the supported components (VEVENT, …) after creation.
|
||||
RefreshCollectionsWorker.enqueue(context, homeSet.serviceId)
|
||||
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
/** Deletes the given collection from the server and the database. */
|
||||
suspend fun delete(collection: Collection) {
|
||||
val service = serviceDao.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
suspend fun deleteRemote(collection: Collection) {
|
||||
val service = serviceRepository.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
HttpClient.Builder(context, AccountSettings(context, account))
|
||||
HttpClient.Builder(context, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
withContext(Dispatchers.IO) {
|
||||
runInterruptible {
|
||||
DavResource(httpClient.okHttpClient, collection.url).delete() {
|
||||
// success, otherwise an exception would have been thrown
|
||||
dao.delete(collection)
|
||||
// success, otherwise an exception would have been thrown → delete locally, too
|
||||
delete(collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
|
||||
|
||||
fun get(id: Long) = dao.get(id)
|
||||
|
||||
fun getFlow(id: Long) = dao.getFlow(id)
|
||||
|
||||
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
|
||||
dao.updateForceReadOnly(id, forceReadOnly)
|
||||
fun getByService(serviceId: Long) = dao.getByService(serviceId)
|
||||
|
||||
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
|
||||
|
||||
fun getByServiceAndSync(serviceId: Long) = dao.getByServiceAndSync(serviceId)
|
||||
|
||||
fun getSyncCalendars(serviceId: Long) = dao.getSyncCalendars(serviceId)
|
||||
|
||||
fun getSyncJtxCollections(serviceId: Long) = dao.getSyncJtxCollections(serviceId)
|
||||
|
||||
fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId)
|
||||
|
||||
/** Returns all collections that are both selected for synchronization and push-capable. */
|
||||
suspend fun getPushCapableAndSyncable(): List<Collection> =
|
||||
dao.getPushCapableSyncCollections()
|
||||
|
||||
suspend fun getPushRegisteredAndNotSyncable(): List<Collection> =
|
||||
dao.getPushRegisteredAndNotSyncable()
|
||||
|
||||
/**
|
||||
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
|
||||
* [Collection.forceReadOnly]), but use the values of the already existing collection.
|
||||
*
|
||||
* @param newCollection Collection to be inserted or updated
|
||||
*/
|
||||
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
|
||||
// remember locally set flags
|
||||
dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
|
||||
newCollection.sync = oldCollection.sync
|
||||
newCollection.forceReadOnly = oldCollection.forceReadOnly
|
||||
}
|
||||
|
||||
// commit to database
|
||||
insertOrUpdateByUrl(newCollection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the existing collection if it exists (URL)
|
||||
*/
|
||||
fun insertOrUpdateByUrl(collection: Collection) {
|
||||
dao.insertOrUpdateByUrl(collection)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
fun pageByServiceAndType(serviceId: Long, type: String) =
|
||||
dao.pageByServiceAndType(serviceId, type)
|
||||
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, type: String) =
|
||||
dao.pagePersonalByServiceAndType(serviceId, type)
|
||||
|
||||
/**
|
||||
* Sets the flag for whether read-only should be enforced on the local collection
|
||||
*/
|
||||
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
|
||||
dao.updateForceReadOnly(id, forceReadOnly)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the local collection should be synced with the server
|
||||
*/
|
||||
suspend fun setSync(id: Long, forceReadOnly: Boolean) {
|
||||
dao.updateSync(id, forceReadOnly)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
|
||||
dao.updatePushSubscription(id, subscriptionUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the collection locally
|
||||
*/
|
||||
fun delete(collection: Collection) {
|
||||
dao.delete(collection)
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
||||
HttpClient.Builder(context, AccountSettings(context, account))
|
||||
HttpClient.Builder(context, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -193,7 +293,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName: String?,
|
||||
description: String?,
|
||||
color: Int? = null,
|
||||
timezoneDef: String? = null,
|
||||
timezoneId: String? = null,
|
||||
supportsVEVENT: Boolean = true,
|
||||
supportsVTODO: Boolean = true,
|
||||
supportsVJOURNAL: Boolean = true
|
||||
@@ -249,9 +349,17 @@ class DavCollectionRepository @Inject constructor(
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
}
|
||||
}
|
||||
timezoneDef?.let {
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
cdsect(it)
|
||||
timezoneId?.let { id ->
|
||||
insertTag(CalendarTimezoneId.NAME) {
|
||||
text(id)
|
||||
}
|
||||
getVTimeZone(id)?.let { vTimezone ->
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
text(
|
||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||
Calendar(ComponentList(listOf(vTimezone))).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +393,34 @@ class DavCollectionRepository @Inject constructor(
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
private fun getVTimeZone(tzId: String): String? =
|
||||
DateUtils.ical4jTimeZone(tzId)?.toString()
|
||||
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
|
||||
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
/**
|
||||
* Notifies registered listeners about changes in the collections.
|
||||
*/
|
||||
private fun notifyOnChangeListeners() = synchronized(listeners) {
|
||||
listeners.forEach { listener ->
|
||||
listener.onCollectionsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnChangeListener {
|
||||
/**
|
||||
* Will be called when collections have changed. Will run in the coroutine context/thread
|
||||
* of the data-modifying method. For instance, if [delete] is called, [onCollectionsChanged]
|
||||
* will be called in the context/thread that called [delete].
|
||||
*/
|
||||
fun onCollectionsChanged()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DavCollectionRepositoryModule {
|
||||
// Provides empty set of listeners
|
||||
@Multibinds abstract fun defaultOnChangeListeners(): Set<OnChangeListener>
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
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
|
||||
|
||||
@@ -18,7 +20,31 @@ class DavHomeSetRepository @Inject constructor(
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -14,9 +14,13 @@ class DavServiceRepository @Inject constructor(
|
||||
|
||||
private val dao = db.serviceDao()
|
||||
|
||||
suspend fun deleteByAccount(accountName: String) {
|
||||
dao.deleteByAccount(accountName)
|
||||
}
|
||||
|
||||
// Read
|
||||
|
||||
fun get(id: Long): Service? = dao.get(id)
|
||||
|
||||
fun getByAccountAndType(name: String, serviceType: String): Service? =
|
||||
dao.getByAccountAndType(name, serviceType)
|
||||
|
||||
fun getCalDavServiceFlow(accountName: String) =
|
||||
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CALDAV)
|
||||
@@ -24,8 +28,21 @@ class DavServiceRepository @Inject constructor(
|
||||
fun getCardDavServiceFlow(accountName: String) =
|
||||
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CARDDAV)
|
||||
|
||||
suspend fun renameAccount(oldName: String, newName: String) {
|
||||
|
||||
// Create & update
|
||||
|
||||
fun insertOrReplace(service: Service) =
|
||||
dao.insertOrReplace(service)
|
||||
|
||||
suspend fun renameAccount(oldName: String, newName: String) =
|
||||
dao.renameAccount(oldName, newName)
|
||||
}
|
||||
|
||||
|
||||
// Delete
|
||||
|
||||
fun deleteAll() = dao.deleteAll()
|
||||
|
||||
suspend fun deleteByAccount(accountName: String) =
|
||||
dao.deleteByAccount(accountName)
|
||||
|
||||
}
|
||||
@@ -4,18 +4,21 @@
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.db.SyncStats
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.text.Collator
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
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
|
||||
@ApplicationContext val context: Context,
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val dao = db.syncStatsDao()
|
||||
@@ -37,6 +40,15 @@ class DavSyncStatsRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun logSyncTime(collectionId: Long, authority: String, lastSync: Long = System.currentTimeMillis()) {
|
||||
dao.insertOrReplace(SyncStats(
|
||||
id = 0,
|
||||
collectionId = collectionId,
|
||||
authority = authority,
|
||||
lastSync = lastSync
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tries to find the application name for given authority. Returns the authority if not
|
||||
@@ -50,9 +62,14 @@ class DavSyncStatsRepository @Inject constructor(
|
||||
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
|
||||
return try {
|
||||
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
if (appInfo != null) {
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} else {
|
||||
logger.warning("Package name ($packageName) not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Logger.log.warning("Application name not found for authority: $authority")
|
||||
logger.warning("Application name not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ package at.bitfire.davdroid.repository
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
@@ -22,26 +21,55 @@ class PreferenceRepository @Inject constructor(
|
||||
context: Application
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val LOG_TO_FILE = "log_to_file"
|
||||
const val UNIFIED_PUSH_ENDPOINT = "unified_push_endpoint"
|
||||
}
|
||||
|
||||
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
|
||||
/**
|
||||
* Updates the "log to file" (verbose logging") setting.
|
||||
* Updates the "log to file" (verbose logging") preference.
|
||||
*/
|
||||
fun logToFile(logToFile: Boolean) {
|
||||
preferences
|
||||
.edit()
|
||||
.putBoolean(Logger.LOG_TO_FILE, logToFile)
|
||||
.putBoolean(LOG_TO_FILE, logToFile)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the "log to file" (verbose logging) setting as a live value.
|
||||
* Gets the "log to file" (verbose logging) preference.
|
||||
*/
|
||||
fun logToFile(): Boolean =
|
||||
preferences.getBoolean(LOG_TO_FILE, false)
|
||||
|
||||
/**
|
||||
* Gets the "log to file" (verbose logging) preference as a live value.
|
||||
*/
|
||||
fun logToFileFlow(): Flow<Boolean> = observeAsFlow(Logger.LOG_TO_FILE) {
|
||||
preferences.getBoolean(Logger.LOG_TO_FILE, false)
|
||||
fun logToFileFlow(): Flow<Boolean> = observeAsFlow(LOG_TO_FILE) {
|
||||
logToFile()
|
||||
}
|
||||
|
||||
|
||||
fun unifiedPushEndpoint() =
|
||||
preferences.getString(UNIFIED_PUSH_ENDPOINT, null)
|
||||
|
||||
fun unifiedPushEndpointFlow() = observeAsFlow(UNIFIED_PUSH_ENDPOINT) {
|
||||
unifiedPushEndpoint()
|
||||
}
|
||||
|
||||
fun unifiedPushEndpoint(endpoint: String?) {
|
||||
preferences
|
||||
.edit()
|
||||
.putString(UNIFIED_PUSH_ENDPOINT, endpoint)
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun<T> observeAsFlow(keyToObserve: String, getValue: () -> T): Flow<T> =
|
||||
callbackFlow {
|
||||
val listener = OnSharedPreferenceChangeListener { _, key ->
|
||||
@@ -51,6 +79,9 @@ class PreferenceRepository @Inject constructor(
|
||||
}
|
||||
preferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
|
||||
// Emit the initial value
|
||||
trySend(getValue())
|
||||
|
||||
awaitClose {
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import javax.inject.Inject
|
||||
|
||||
class PrincipalRepository @Inject constructor(
|
||||
db: AppDatabase
|
||||
) {
|
||||
|
||||
private val dao = db.principalDao()
|
||||
|
||||
fun get(id: Long): Principal = dao.get(id)
|
||||
|
||||
}
|
||||
@@ -17,147 +17,67 @@ import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.util.Base64
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.syncadapter.AccountUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
import at.bitfire.vcard4android.Constants
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import java.io.ByteArrayOutputStream
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* A local address book. Requires an own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book. These accounts are bound to a
|
||||
* DAVx5 main account.
|
||||
* address book" account for every CardDAV address book.
|
||||
*
|
||||
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
|
||||
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
|
||||
* the new name will only be available in [addressBookAccount], so usually that one should be used.
|
||||
*
|
||||
* @param provider Content provider needed to access and modify the address book
|
||||
*/
|
||||
open class LocalAddressBook(
|
||||
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")
|
||||
@OpenForTesting
|
||||
open class LocalAddressBook @AssistedInject constructor(
|
||||
@Assisted _addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
override val tag: String
|
||||
get() = "contacts-${addressBookAccount.name}"
|
||||
|
||||
override val title
|
||||
get() = addressBookAccount.name
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
@@ -167,48 +87,31 @@ open class LocalAddressBook(
|
||||
* 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())
|
||||
val manager = AccountManager.get(context)
|
||||
val account = manager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (account == null)
|
||||
throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account")
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
accountSettings.getGroupMethod()
|
||||
}
|
||||
val includeGroups
|
||||
private val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
private var _mainAccount: Account? = null
|
||||
/**
|
||||
* The associated main account which this address book's accounts belong to.
|
||||
*
|
||||
* @throws IllegalArgumentException when [account] is not an address book account or when no main account is assigned
|
||||
*/
|
||||
open var mainAccount: Account?
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
val result = mainAccount(context, account)
|
||||
_mainAccount = result
|
||||
return result
|
||||
}
|
||||
set(newMainAccount) {
|
||||
if (newMainAccount == null)
|
||||
throw IllegalArgumentException("Main account must not be null")
|
||||
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
|
||||
}
|
||||
|
||||
_mainAccount = newMainAccount
|
||||
}
|
||||
fun requireMainAccount(): Account =
|
||||
mainAccount ?: throw IllegalArgumentException("No main account assigned to address book $account")
|
||||
|
||||
var url: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
override var collectionUrl: String
|
||||
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_URL, url)
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
@@ -248,48 +151,104 @@ open class LocalAddressBook(
|
||||
* Updates the address book settings.
|
||||
*
|
||||
* @param info collection where to take the settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only";
|
||||
* `false`: determine read-only flag from [info];
|
||||
* `null`: don't change the existing value
|
||||
*/
|
||||
fun update(info: Collection, forceReadOnly: Boolean) {
|
||||
val newAccountName = accountName(requireMainAccount(), info)
|
||||
fun update(info: Collection, forceReadOnly: Boolean? = null) {
|
||||
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
if (account.name != newAccountName) {
|
||||
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
|
||||
val accountManager = AccountManager.get(context)
|
||||
val future = accountManager.renameAccount(account, newAccountName, null, null)
|
||||
account = future.result
|
||||
}
|
||||
// Update the account name
|
||||
val newAccountName = accountName(context, info)
|
||||
if (addressBookAccount.name != newAccountName)
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
renameAccount(newAccountName)
|
||||
|
||||
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
// Update the account user data
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
|
||||
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
// Update force read only
|
||||
if (forceReadOnly != null) {
|
||||
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
logger.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1)
|
||||
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1)
|
||||
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
/**
|
||||
* Renames an address book account and moves the contacts and groups (without making them dirty).
|
||||
* Does not keep user data of the old account, so these have to be set again.
|
||||
*
|
||||
* On success, [addressBookAccount] will be updated to the new account name.
|
||||
*
|
||||
* _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never
|
||||
* a moment when both accounts are available.
|
||||
*
|
||||
* @param newName the new account name (account type is taken from [addressBookAccount])
|
||||
*
|
||||
* @return whether the account was renamed successfully
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun renameAccount(newName: String): Boolean {
|
||||
val oldAccount = addressBookAccount
|
||||
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
|
||||
|
||||
// create new account
|
||||
val newAccount = Account(newName, oldAccount.type)
|
||||
if (!SystemAccountUtils.createAccount(context, newAccount, Bundle()))
|
||||
return false
|
||||
|
||||
// move contacts and groups to new account
|
||||
val batch = BatchOperation(provider!!)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(groupsSyncUri())
|
||||
.withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
|
||||
.withValue(Groups.ACCOUNT_NAME, newAccount.name)
|
||||
)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(rawContactsSyncUri())
|
||||
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
|
||||
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
|
||||
)
|
||||
batch.commit()
|
||||
|
||||
// update AndroidAddressBook.account
|
||||
addressBookAccount = newAccount
|
||||
|
||||
// delete old account
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
accountManager.removeAccountExplicitly(oldAccount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteCollection(): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
@@ -303,15 +262,15 @@ open class LocalAddressBook(
|
||||
*/
|
||||
fun updateSyncFrameworkSettings() {
|
||||
// Enable sync-ability
|
||||
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
|
||||
|
||||
// Enable content trigger
|
||||
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
if (!ContentResolver.getSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(addressBookAccount, ContactsContract.AUTHORITY))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
}
|
||||
|
||||
@@ -403,10 +362,10 @@ open class LocalAddressBook(
|
||||
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)
|
||||
logger.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)
|
||||
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
@@ -444,9 +403,134 @@ open class LocalAddressBook(
|
||||
// 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)
|
||||
logger.log(Level.FINE, "Deleting group", group)
|
||||
group.delete()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LocalAddressBookCompanionEntryPoint {
|
||||
fun localAddressBookFactory(): Factory
|
||||
fun serviceRepository(): DavServiceRepository
|
||||
fun logger(): Logger
|
||||
}
|
||||
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_COLLECTION_ID = "collection_id"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
// create/query/delete
|
||||
|
||||
/**
|
||||
* Creates a new local address book.
|
||||
*
|
||||
* @param context app context to resolve string resources
|
||||
* @param provider contacts provider client
|
||||
* @param info collection where to take the name and settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
*/
|
||||
fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val logger = entryPoint.logger()
|
||||
|
||||
val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(info.url.toString(), info.id.toString())
|
||||
logger.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val factory = entryPoint.localAddressBookFactory()
|
||||
val addressBook = factory.create(account, provider)
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a [LocalAddressBook] based on its corresponding collection.
|
||||
*
|
||||
* @param id collection ID to look for
|
||||
*
|
||||
* @return The [LocalAddressBook] for the given collection or *null* if not found
|
||||
*/
|
||||
fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val factory = entryPoint.localAddressBookFactory()
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { account ->
|
||||
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
.map { account -> factory.create(account, provider) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a [LocalAddressBook] based on its corresponding database collection.
|
||||
*
|
||||
* @param id collection ID to look for
|
||||
*/
|
||||
fun deleteByCollection(context: Context, id: Long) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
|
||||
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
if (addressBookAccount != null)
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Creates a name for the address book account from its corresponding db collection info.
|
||||
*
|
||||
* The address book account name contains
|
||||
* - the collection display name or last URL path segment
|
||||
* - the actual account name
|
||||
* - the collection ID, to make it unique.
|
||||
*
|
||||
* @param info The corresponding collection
|
||||
*/
|
||||
fun accountName(context: Context, info: Collection): String {
|
||||
// Name the address book after given collection display name, otherwise use last URL path segment
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
info.url.lastSegment
|
||||
else
|
||||
it
|
||||
})
|
||||
// Add the actual account name to the address book account name
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val serviceRepository = entryPoint.serviceRepository()
|
||||
serviceRepository.get(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
sb.append(" #${info.id}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun initialUserData(url: String, collectionId: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_COLLECTION_ID, collectionId)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,8 +14,7 @@ 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.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
@@ -23,19 +22,32 @@ import at.bitfire.ical4android.util.DateUtils
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Application-specific subclass of [AndroidCalendar] for local calendars.
|
||||
*
|
||||
* [Calendars.NAME] is used to store the calendar URL.
|
||||
*/
|
||||
class LocalCalendar private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
id: Long
|
||||
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
|
||||
|
||||
private val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(info, withColor = true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
@@ -57,8 +69,8 @@ class LocalCalendar private constructor(
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
if (withColor && info.color != null)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
@@ -74,7 +86,7 @@ class LocalCalendar private constructor(
|
||||
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)
|
||||
logger.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +98,9 @@ class LocalCalendar private constructor(
|
||||
|
||||
}
|
||||
|
||||
override val collectionUrl: String?
|
||||
get() = name
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
@@ -96,6 +111,8 @@ class LocalCalendar private constructor(
|
||||
override val readOnly
|
||||
get() = accessLevel <= Calendars.CAL_ACCESS_READ
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
@@ -116,11 +133,11 @@ class LocalCalendar private constructor(
|
||||
}
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
@@ -145,7 +162,7 @@ class LocalCalendar private constructor(
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
dirty += localEvent
|
||||
}
|
||||
@@ -194,14 +211,14 @@ class LocalCalendar private constructor(
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
logger.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)")
|
||||
logger.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)
|
||||
|
||||
@@ -231,14 +248,14 @@ class LocalCalendar private constructor(
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
logger.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")
|
||||
logger.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)
|
||||
@@ -278,7 +295,7 @@ class LocalCalendar private constructor(
|
||||
|
||||
// delete event if there are no instances
|
||||
if (numEventInstances == 0) {
|
||||
Logger.log.info("Marking event #$eventID without instances as deleted")
|
||||
logger.info("Marking event #$eventID without instances as deleted")
|
||||
LocalEvent.markAsDeleted(provider, account, eventID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ import at.bitfire.davdroid.db.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
/** A tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
|
||||
/** Address of the remote collection */
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
val collectionUrl: String?
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
|
||||
@@ -23,6 +27,13 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*/
|
||||
val readOnly: Boolean
|
||||
|
||||
/**
|
||||
* Deletes the local collection.
|
||||
*
|
||||
* @return true if the collection was deleted, false otherwise
|
||||
*/
|
||||
fun deleteCollection(): Boolean
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *deleted* by the user
|
||||
* or an app acting on their behalf.
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -26,21 +25,25 @@ import at.bitfire.vcard4android.Contact
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.UUID
|
||||
import java.util.logging.Logger
|
||||
|
||||
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
|
||||
Contact.productID = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
}
|
||||
|
||||
private val logger: Logger = Logger.getGlobal()
|
||||
|
||||
override val addressBook: LocalAddressBook
|
||||
get() = super.addressBook as LocalAddressBook
|
||||
|
||||
internal val cachedGroupMemberships = HashSet<Long>()
|
||||
internal val groupMemberships = HashSet<Long>()
|
||||
|
||||
@@ -102,7 +105,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
// 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")
|
||||
logger.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
@@ -148,7 +151,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = getContact().hashCode()
|
||||
val groupHash = groupMemberships.hashCode()
|
||||
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
|
||||
logger.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
@@ -157,7 +160,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
logger.fine("Storing contact hash = $hashCode")
|
||||
|
||||
if (batch == null) {
|
||||
val values = ContentValues(1)
|
||||
|
||||
@@ -25,7 +25,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
|
||||
ICalendar.prodId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = Events.SYNC_DATA1
|
||||
|
||||
@@ -14,7 +14,7 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
@@ -22,13 +22,16 @@ import at.bitfire.vcard4android.AndroidGroupFactory
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.LinkedList
|
||||
import java.util.UUID
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
private val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
const val COLUMN_FLAGS = Groups.SYNC4
|
||||
|
||||
@@ -44,7 +47,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
* @param addressBook address book to take groups from
|
||||
*/
|
||||
fun applyPendingMemberships(addressBook: LocalAddressBook) {
|
||||
Logger.log.info("Assigning memberships of contact groups")
|
||||
logger.info("Assigning memberships of contact groups")
|
||||
|
||||
addressBook.allGroups { group ->
|
||||
val groupId = group.id!!
|
||||
@@ -59,7 +62,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
val uid = addressBook.getContactUidFromId(currentMemberId) ?: continue
|
||||
|
||||
if (!pendingMemberUids.contains(uid)) {
|
||||
Logger.log.fine("$currentMemberId removed from group $groupId; removing group membership")
|
||||
logger.fine("$currentMemberId removed from group $groupId; removing group membership")
|
||||
val currentMember = addressBook.findContactById(currentMemberId)
|
||||
currentMember.removeGroupMemberships(batch)
|
||||
|
||||
@@ -76,11 +79,11 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
for (missingMemberUid in pendingMemberUids) {
|
||||
val missingMember = addressBook.findContactByUid(missingMemberUid)
|
||||
if (missingMember == null) {
|
||||
Logger.log.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring")
|
||||
logger.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring")
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.log.fine("Assigning member $missingMember to group $groupId")
|
||||
logger.fine("Assigning member $missingMember to group $groupId")
|
||||
missingMember.addToGroup(batch, groupId)
|
||||
|
||||
// Android 7 hack
|
||||
@@ -134,7 +137,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = StringUtils.trimToNull(cursor.getString(0))
|
||||
uid = cursor.getString(0).trimToNull()
|
||||
}
|
||||
|
||||
if (uid == null) {
|
||||
|
||||
@@ -7,17 +7,17 @@ package at.bitfire.davdroid.resource
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.davdroid.util.DavUtils.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
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
|
||||
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
|
||||
@@ -25,9 +25,14 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) {
|
||||
val values = valuesFromCollection(info, account, owner, true)
|
||||
create(account, client, values)
|
||||
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollection(info, account, owner, withColor = true)
|
||||
|
||||
return create(account, client, values)
|
||||
}
|
||||
|
||||
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
|
||||
@@ -40,10 +45,11 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
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")
|
||||
else
|
||||
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
|
||||
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
|
||||
if (withColor)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
if (withColor && info.color != null)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
|
||||
@@ -56,16 +62,20 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override val tag: String
|
||||
get() = "jtx-${account.name}-$id"
|
||||
override val collectionUrl: String?
|
||||
get() = url
|
||||
override val title: String
|
||||
get() = displayname ?: id.toString()
|
||||
override var lastSyncState: SyncState?
|
||||
get() = SyncState.fromString(syncstate)
|
||||
set(value) { syncstate = value.toString() }
|
||||
|
||||
fun updateCollection(info: Collection, owner: Principal?, withColor: Boolean) {
|
||||
val values = valuesFromCollection(info, account, owner, withColor)
|
||||
fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) {
|
||||
val values = valuesFromCollection(info, account, owner, updateColor)
|
||||
update(values)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user