mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-06 05:01:50 -05:00
Compare commits
461 Commits
v4.4.4-ose
...
split-core
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce9b83356 | ||
|
|
47b4ecd705 | ||
|
|
bced7e5ee5 | ||
|
|
360c2249cf | ||
|
|
53791871c6 | ||
|
|
9bddf4e8d4 | ||
|
|
eab054d1c3 | ||
|
|
5e84648fb4 | ||
|
|
490abcb88a | ||
|
|
cca12e79d8 | ||
|
|
915cf73027 | ||
|
|
53773eaf83 | ||
|
|
9cd685982d | ||
|
|
d4902e84ce | ||
|
|
ec485fcfa5 | ||
|
|
5709aaa2e5 | ||
|
|
a19c397ef6 | ||
|
|
dad4298dd5 | ||
|
|
8e78e6e3ac | ||
|
|
fc878d519f | ||
|
|
45d5d809fc | ||
|
|
ef1d90f740 | ||
|
|
5efcbfc5a3 | ||
|
|
4f3ff69b43 | ||
|
|
afe00c275e | ||
|
|
03c4aa9938 | ||
|
|
63a5359c06 | ||
|
|
89a7cd2885 | ||
|
|
db25570581 | ||
|
|
3de34e53d0 | ||
|
|
af084fb5d0 | ||
|
|
47685e6693 | ||
|
|
2c7b36ecd5 | ||
|
|
cf80b11808 | ||
|
|
18649f711a | ||
|
|
377a159e75 | ||
|
|
393d22f720 | ||
|
|
5b12ecf6b6 | ||
|
|
f8f6134640 | ||
|
|
0f7908da23 | ||
|
|
40741f52e1 | ||
|
|
210a03bd1a | ||
|
|
714c92b8d9 | ||
|
|
5ea937a0f9 | ||
|
|
126b742887 | ||
|
|
2de7e09c82 | ||
|
|
0312f59aab | ||
|
|
b3682ded1a | ||
|
|
529378000a | ||
|
|
8644c94c34 | ||
|
|
5357bdefb8 | ||
|
|
07b646e4e7 | ||
|
|
77cb3e659d | ||
|
|
42d65c872e | ||
|
|
c3fd28f820 | ||
|
|
9ad98e4a16 | ||
|
|
27c1be948f | ||
|
|
ac7ef1a7e5 | ||
|
|
e0eb13f57b | ||
|
|
85c4fc76f2 | ||
|
|
6bd9422f3b | ||
|
|
9754770238 | ||
|
|
6be15fd366 | ||
|
|
0cb27f0c2f | ||
|
|
776305bd12 | ||
|
|
01f54df3c0 | ||
|
|
4944ce59b1 | ||
|
|
0e455d8371 | ||
|
|
a938b511cd | ||
|
|
d32b86789b | ||
|
|
84d58f73db | ||
|
|
cc43998148 | ||
|
|
b354bfebc2 | ||
|
|
29240ea16f | ||
|
|
e7b88f9aa8 | ||
|
|
4d71517cde | ||
|
|
0d4f154baf | ||
|
|
9eb70a5564 | ||
|
|
24e0a864bd | ||
|
|
10ec0c3b6d | ||
|
|
bd3349cc38 | ||
|
|
c7bc2b317b | ||
|
|
2d10cbb07d | ||
|
|
88928792af | ||
|
|
b5e8c80db1 | ||
|
|
6f09f55e1a | ||
|
|
b08f10a98f | ||
|
|
a3a952d875 | ||
|
|
e9fc570895 | ||
|
|
098b7d5b12 | ||
|
|
a38dc29cca | ||
|
|
84b9a14ba1 | ||
|
|
cda95dc789 | ||
|
|
f64882ca2a | ||
|
|
7c2dcf3d70 | ||
|
|
66a34ebd9f | ||
|
|
365364aa89 | ||
|
|
114543f4c5 | ||
|
|
3bd3f56e1b | ||
|
|
5263172376 | ||
|
|
babd52cfb1 | ||
|
|
3d4d533b92 | ||
|
|
76fc024ef6 | ||
|
|
794b4c1c7f | ||
|
|
aac6356722 | ||
|
|
9bc46d4194 | ||
|
|
a3aac44775 | ||
|
|
084ba3b630 | ||
|
|
28dcf90775 | ||
|
|
70766affd9 | ||
|
|
d00292f421 | ||
|
|
6b5c4f191a | ||
|
|
5c7b792e7f | ||
|
|
c9da496142 | ||
|
|
ee098c4a83 | ||
|
|
a8bd296520 | ||
|
|
0959624dee | ||
|
|
bd13d27e38 | ||
|
|
85548163ca | ||
|
|
026750eca3 | ||
|
|
d365a504e8 | ||
|
|
c64cb1e7ec | ||
|
|
837b5e5d50 | ||
|
|
98aefc4fee | ||
|
|
a8c8a8d2e0 | ||
|
|
b839cbfe7f | ||
|
|
f0f9f58e49 | ||
|
|
66f6e48e3b | ||
|
|
d6feda1142 | ||
|
|
05f058ab3f | ||
|
|
4e4c0f5e31 | ||
|
|
0304d7168a | ||
|
|
19458aa95c | ||
|
|
4412617079 | ||
|
|
76277dbfd5 | ||
|
|
b0b99de56b | ||
|
|
b33c4750bb | ||
|
|
25b749dd1b | ||
|
|
39a0fe3f98 | ||
|
|
dd798f8380 | ||
|
|
47d380de62 | ||
|
|
019d32a9b7 | ||
|
|
0acabd9c80 | ||
|
|
aa23980a59 | ||
|
|
5674d6b954 | ||
|
|
df56c8628a | ||
|
|
af4ecd3a1d | ||
|
|
701e292ab5 | ||
|
|
5da7d9e292 | ||
|
|
e6256764ac | ||
|
|
e63f815416 | ||
|
|
374dadfaaa | ||
|
|
f6ef13f9fe | ||
|
|
a88cfd2acf | ||
|
|
98fc946594 | ||
|
|
00523d9bc8 | ||
|
|
53d338d03e | ||
|
|
0424999225 | ||
|
|
fa09a0560f | ||
|
|
f4aa55d482 | ||
|
|
1bffd5efe1 | ||
|
|
4850f2a5a5 | ||
|
|
53f38ce2ec | ||
|
|
7cf6e30577 | ||
|
|
cec77c33cb | ||
|
|
6c98e6d501 | ||
|
|
a7cd1cd49f | ||
|
|
0cc84dfd01 | ||
|
|
87239daaf6 | ||
|
|
81ceb57842 | ||
|
|
cd0b0c0804 | ||
|
|
48cbd4a05d | ||
|
|
beccc7a0d4 | ||
|
|
2b629c8b18 | ||
|
|
cd725479cd | ||
|
|
44666d2138 | ||
|
|
8e67db7d54 | ||
|
|
a58e3b9036 | ||
|
|
d63918ff42 | ||
|
|
f21c3de94a | ||
|
|
24d4ba65e5 | ||
|
|
ae96f1ffbb | ||
|
|
a08ecae635 | ||
|
|
eb4224780a | ||
|
|
0240e67dab | ||
|
|
0ccd9d5eb3 | ||
|
|
438f967152 | ||
|
|
a093238864 | ||
|
|
293daf1e82 | ||
|
|
3b50747ce9 | ||
|
|
51d6ed279a | ||
|
|
2c6842ac0c | ||
|
|
0e6644305a | ||
|
|
10e3b0a723 | ||
|
|
2f45b705b3 | ||
|
|
be6c3311d7 | ||
|
|
755863778b | ||
|
|
0e81866d3a | ||
|
|
93a256ee75 | ||
|
|
61e9d60b7c | ||
|
|
dc9fb7b608 | ||
|
|
44b52f65a2 | ||
|
|
e13c140554 | ||
|
|
cdb50205f4 | ||
|
|
2ba4a2a510 | ||
|
|
38b2377760 | ||
|
|
10f6356a6e | ||
|
|
df4b6d3fbc | ||
|
|
dab948730e | ||
|
|
288583bfad | ||
|
|
98c0b0c36a | ||
|
|
ed7a477d3f | ||
|
|
b0609fafb2 | ||
|
|
94a85833bc | ||
|
|
4c5c8c3ed0 | ||
|
|
4685ab6d0c | ||
|
|
62a0ba3520 | ||
|
|
71f3558b4b | ||
|
|
22d933096f | ||
|
|
666b707854 | ||
|
|
39f6b82926 | ||
|
|
b02fd23f0a | ||
|
|
ca56380c29 | ||
|
|
ba9eb1446b | ||
|
|
055599c74f | ||
|
|
62db3da579 | ||
|
|
76d8d5acbf | ||
|
|
dd294a4b03 | ||
|
|
0efe6a7b9b | ||
|
|
405b7abb39 | ||
|
|
4e2640ca01 | ||
|
|
904c8ba29b | ||
|
|
62dc73c2a0 | ||
|
|
58344099f7 | ||
|
|
b62c7eff0b | ||
|
|
12cedd4010 | ||
|
|
3a0221c749 | ||
|
|
f78e7868e8 | ||
|
|
5dbaedfa60 | ||
|
|
6187f92efd | ||
|
|
82ccf6a2f9 | ||
|
|
0f9c5027d4 | ||
|
|
7b76df3e70 | ||
|
|
80cfe1013d | ||
|
|
3e3c346019 | ||
|
|
1773dff8a4 | ||
|
|
604b0aab98 | ||
|
|
35cffa603b | ||
|
|
89c3eacd36 | ||
|
|
4246ed65ac | ||
|
|
789e7f3045 | ||
|
|
66f99f7362 | ||
|
|
90b04ddbdc | ||
|
|
a7f8ea8a48 | ||
|
|
42cd8d8631 | ||
|
|
a26847cf10 | ||
|
|
0e6c26aec6 | ||
|
|
2204027993 | ||
|
|
e7189d66b0 | ||
|
|
c517647819 | ||
|
|
0780b226ff | ||
|
|
8d9a417753 | ||
|
|
008c314b80 | ||
|
|
f0019c54b1 | ||
|
|
51ad6ee00a | ||
|
|
dd453a7837 | ||
|
|
f91c968eb8 | ||
|
|
968a43f9cc | ||
|
|
ac965b411b | ||
|
|
1608384418 | ||
|
|
f25c22eba5 | ||
|
|
b1f742fb3a | ||
|
|
47c8a0589d | ||
|
|
7c6474ce91 | ||
|
|
de8c1d160d | ||
|
|
fa50fe4c30 | ||
|
|
ba4d3b2fd1 | ||
|
|
0fed85fdc3 | ||
|
|
6fbaea9487 | ||
|
|
fc2bc8aa47 | ||
|
|
0321e4ab8f | ||
|
|
711543c5f1 | ||
|
|
5c485834e9 | ||
|
|
f349f1fec8 | ||
|
|
e6413506cb | ||
|
|
d4b5039297 | ||
|
|
979f2257de | ||
|
|
3efb8d5c62 | ||
|
|
ec657519a9 | ||
|
|
a835557b35 | ||
|
|
19f86670bf | ||
|
|
f74d14e2a2 | ||
|
|
57ef059099 | ||
|
|
f157a819b7 | ||
|
|
9bbc4c096d | ||
|
|
b306219015 | ||
|
|
469c30b511 | ||
|
|
05f6c7ab0b | ||
|
|
eeb94d4039 | ||
|
|
7bf9172bdc | ||
|
|
dec5be5690 | ||
|
|
d8e8129d7b | ||
|
|
c9fb7dc7a2 | ||
|
|
ededcb98e1 | ||
|
|
fc10a315d5 | ||
|
|
cfeb6b3974 | ||
|
|
a15902e586 | ||
|
|
47afddbd08 | ||
|
|
5f647b7403 | ||
|
|
d460e4ca7b | ||
|
|
827a1b954f | ||
|
|
54bcda1bb4 | ||
|
|
7003c5f730 | ||
|
|
4ccf99ce23 | ||
|
|
3fca4d60f1 | ||
|
|
2099f47d22 | ||
|
|
c12a723a52 | ||
|
|
eb0b75a9a7 | ||
|
|
94ca9cd871 | ||
|
|
9f697f06be | ||
|
|
ba7f95aad5 | ||
|
|
993fffaa15 | ||
|
|
4b7f7ed45e | ||
|
|
7e80607a34 | ||
|
|
d0389f13fc | ||
|
|
993d0f66ec | ||
|
|
4c9ad959dd | ||
|
|
044a28138a | ||
|
|
e8ec98c257 | ||
|
|
fe0c1e67e7 | ||
|
|
7261a8137d | ||
|
|
b1493f3f6a | ||
|
|
d679dc5e97 | ||
|
|
b0f7196f2b | ||
|
|
77a6e5c5ab | ||
|
|
845d979046 | ||
|
|
f62509ed80 | ||
|
|
e79c362f46 | ||
|
|
5c35741226 | ||
|
|
b90b8ce6a2 | ||
|
|
321aeedd8f | ||
|
|
09f68a237b | ||
|
|
93d715bb99 | ||
|
|
04fe8e1aca | ||
|
|
1f02f3cc27 | ||
|
|
a0acd4e929 | ||
|
|
3901e6ebe4 | ||
|
|
f229226521 | ||
|
|
6644e4acd7 | ||
|
|
d46f8056a5 | ||
|
|
c3731ace88 | ||
|
|
038c2df524 | ||
|
|
400318b390 | ||
|
|
0d2e5a1f07 | ||
|
|
9835cd0d53 | ||
|
|
f6d8efcd26 | ||
|
|
955de83b35 | ||
|
|
29a09f2038 | ||
|
|
fff332f31f | ||
|
|
b4d4a2fddd | ||
|
|
566a539a85 | ||
|
|
e588ada891 | ||
|
|
0c89e3ba3b | ||
|
|
7b0e134c20 | ||
|
|
8f7c285cb7 | ||
|
|
a2cddfc012 | ||
|
|
54eaecc6b5 | ||
|
|
0012dec482 | ||
|
|
ced6abea3f | ||
|
|
4a82baeaea | ||
|
|
f41b4fd59d | ||
|
|
f6bd4b0fc2 | ||
|
|
50879b6a0c | ||
|
|
6be42d4ec3 | ||
|
|
a56d42d9a5 | ||
|
|
e34952bca9 | ||
|
|
9d293a00e7 | ||
|
|
1ee41f8027 | ||
|
|
d3c1dbb5da | ||
|
|
cd554d885b | ||
|
|
d9b4149d41 | ||
|
|
4af6165094 | ||
|
|
fb2023762d | ||
|
|
946c450036 | ||
|
|
969d92d037 | ||
|
|
6998f009c4 | ||
|
|
cba1f01bdb | ||
|
|
5f80c8e779 | ||
|
|
5ece438b3f | ||
|
|
acd4e41f8b | ||
|
|
ae08093906 | ||
|
|
dd456b41f1 | ||
|
|
30283f36a4 | ||
|
|
4858dd9229 | ||
|
|
b910ba25ae | ||
|
|
2a542210ca | ||
|
|
eef85f1f7a | ||
|
|
4f2d4e3a49 | ||
|
|
feccb76ce8 | ||
|
|
835689a4a6 | ||
|
|
50cbac147e | ||
|
|
d1dd2f016e | ||
|
|
bd5e26a9a9 | ||
|
|
2d686bee01 | ||
|
|
2438f1a8d4 | ||
|
|
331f8d5743 | ||
|
|
7e43524ff5 | ||
|
|
708d94b69b | ||
|
|
8b3c36f702 | ||
|
|
ca2d57cf61 | ||
|
|
1cc9b4bdd1 | ||
|
|
226583f19e | ||
|
|
f3333b7b54 | ||
|
|
226560230d | ||
|
|
6a08497b3a | ||
|
|
356183084f | ||
|
|
5ea7273c94 | ||
|
|
843013a0f0 | ||
|
|
ac8de37b6f | ||
|
|
62dc374774 | ||
|
|
1f83e1bf12 | ||
|
|
4c9b67a9e5 | ||
|
|
4cbe03b351 | ||
|
|
365f87991a | ||
|
|
77a795dfe5 | ||
|
|
794007fa38 | ||
|
|
1e17e1883b | ||
|
|
48ecb5e008 | ||
|
|
f503ce5ff6 | ||
|
|
98578feeb2 | ||
|
|
0762cc6c27 | ||
|
|
b267291e93 | ||
|
|
eb8db47cea | ||
|
|
7384feeafb | ||
|
|
d10add8367 | ||
|
|
51bd163069 | ||
|
|
90280066ee | ||
|
|
03a52e96ad | ||
|
|
5890b3cc5e | ||
|
|
a02bc56b44 | ||
|
|
4939c9fc4d | ||
|
|
c2524b085e | ||
|
|
d892dd2b9c | ||
|
|
95ebce5722 | ||
|
|
4b2f032a57 | ||
|
|
bc596edfb3 | ||
|
|
e18534ab9f | ||
|
|
9ae03dbc6f | ||
|
|
042dd3fba2 | ||
|
|
5d6959c47e | ||
|
|
239038ab77 | ||
|
|
7097bf9523 | ||
|
|
53bc5a6641 | ||
|
|
9e060f6651 | ||
|
|
cc8fc4734f | ||
|
|
0733fef213 | ||
|
|
f977cc01eb | ||
|
|
30dc2cb221 | ||
|
|
fc6b605693 | ||
|
|
4f1176fd99 | ||
|
|
4ff7ff8746 |
8
.github/CODEOWNERS
vendored
Normal file
8
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# See https://docs.github.com/de/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
# For combination with "Require review from code owners" for main-ose branch.
|
||||
|
||||
# Dependabot
|
||||
gradle/** @bitfireAT/app-dev
|
||||
|
||||
# everything else
|
||||
* @rfc2822
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: DAVx⁵ Community Support
|
||||
url: https://github.com/bitfireAT/davx5-ose/discussions
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
3
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Qualified Bug Report
|
||||
description: "[Developers only] For qualified bug reports. (Use Discussions if unsure.)"
|
||||
description: "For qualified bug reports. (Use Discussions if unsure.)"
|
||||
type: bug
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
3
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Qualified Feature Request
|
||||
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
|
||||
description: "For qualified feature requests. (Use Discussions if unsure.)"
|
||||
type: feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
32
.github/dependabot.yml
vendored
Normal file
32
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Workflow files stored in the default location of `.github/workflows`
|
||||
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
labels:
|
||||
- "github_actions"
|
||||
- "dependencies"
|
||||
groups:
|
||||
ci-actions:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels: # don't create "java" label (default for gradle ecosystem)
|
||||
- "dependencies"
|
||||
groups:
|
||||
app-dependencies:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# dependencies without semantic versioning
|
||||
- dependency-name: "com.github.bitfireat:cert4android"
|
||||
- dependency-name: "com.github.bitfireat:dav4jvm"
|
||||
- dependency-name: "com.github.bitfireat:synctools"
|
||||
28
.github/workflows/codeql.yml
vendored
28
.github/workflows/codeql.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
branches: [ main-ose ]
|
||||
schedule:
|
||||
- cron: '22 10 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -21,38 +22,29 @@ jobs:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'java' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
languages: java-kotlin
|
||||
build-mode: manual # autobuild uses older JDK
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
#- name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
|
||||
- name: Build # we must not use build cache here
|
||||
run: ./gradlew --no-daemon --configuration-cache app:assembleDebug
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
24
.github/workflows/dependency-submission.yml
vendored
Normal file
24
.github/workflows/dependency-submission.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Dependency Submission
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main-ose' ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
dependency-submission:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Generate and submit dependency graph
|
||||
uses: gradle/actions/dependency-submission@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'
|
||||
55
.github/workflows/dependent-issues.yml
vendored
55
.github/workflows/dependent-issues.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Dependent Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
- reopened
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
- reopened
|
||||
# Makes sure we always add status check for PRs. Useful only if
|
||||
# this action is required to pass before merging. Otherwise, it
|
||||
# can be removed.
|
||||
- synchronize
|
||||
|
||||
# Schedule a daily check. Useful if you reference cross-repository
|
||||
# issues or pull requests. Otherwise, it can be removed.
|
||||
schedule:
|
||||
- cron: '19 9 * * *'
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: z0al/dependent-issues@v1
|
||||
env:
|
||||
# (Required) The token to use to make API calls to GitHub.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# (Optional) The token to use to make API calls to GitHub for remote repos.
|
||||
GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }}
|
||||
|
||||
with:
|
||||
# (Optional) The label to use to mark dependent issues
|
||||
# label: dependent
|
||||
|
||||
# (Optional) Enable checking for dependencies in issues.
|
||||
# Enable by setting the value to "on". Default "off"
|
||||
check_issues: on
|
||||
|
||||
# (Optional) A comma-separated list of keywords. Default
|
||||
# "depends on, blocked by"
|
||||
keywords: depends on, blocked by
|
||||
|
||||
# (Optional) A custom comment body. It supports `{{ dependencies }}` token.
|
||||
comment: >
|
||||
This PR/issue depends on:
|
||||
|
||||
{{ dependencies }}
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -19,19 +19,19 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Prepare keystore
|
||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||
|
||||
- name: Build signed package
|
||||
# Make sure that caches are disabled to generate reproducible release builds
|
||||
run: ./gradlew --no-build-cache --no-configuration-cache --no-daemon app:assembleRelease
|
||||
# Use build cache to speed up building of build variants, but clean caches from previous tests before
|
||||
run: ./gradlew --build-cache --configuration-cache --no-daemon app:clean app:assembleRelease
|
||||
env:
|
||||
ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }}
|
||||
@@ -45,4 +45,3 @@ jobs:
|
||||
files: app/build/outputs/apk/ose/release/*.apk
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
discussion_category_name: Announcements
|
||||
|
||||
113
.github/workflows/test-dev.yml
vendored
113
.github/workflows/test-dev.yml
vendored
@@ -2,80 +2,135 @@ name: Development tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- 'main-ose'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We provide a remote gradle build cache. Take the settings from the secrets and enable
|
||||
# configuration and build cache for all gradle jobs.
|
||||
#
|
||||
# Note: The secrets are not available for forks and Dependabot PRs.
|
||||
env:
|
||||
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
|
||||
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
|
||||
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
|
||||
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile for build cache
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
name: Compile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
cache-read-only: false # allow branches to update their configuration cache
|
||||
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
|
||||
|
||||
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
|
||||
- name: Cache Android environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.config/.android # needs to be cached so that configuration cache can work
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
test:
|
||||
- name: Compile
|
||||
run: ./gradlew app:compileOseDebugSource
|
||||
|
||||
# Cache configurations for the other jobs (including assemble for CodeQL)
|
||||
- name: Populate configuration cache
|
||||
run: |
|
||||
./gradlew --dry-run core:assembleDebug app:assembleDebug
|
||||
./gradlew --dry-run core:lintDebug app:lintOseDebug
|
||||
./gradlew --dry-run core:testDebugUnitTest app:testOseDebugUnitTest
|
||||
./gradlew --dry-run core:virtualDebugAndroidTest app:virtualOseDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
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
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Run lint
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
|
||||
- name: Run unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
test_on_emulator:
|
||||
- name: Lint checks
|
||||
run: ./gradlew core:lintDebug app:lintOseDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew core:testDebugUnitTest app:testOseDebugUnitTest
|
||||
|
||||
instrumented_tests:
|
||||
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
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
# gradle and Android SDK often take more space than what is available on the default runner.
|
||||
# We try to free a few GB here to make gradle-managed devices more reliable.
|
||||
- name: Free some disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
android: false # we need the Android SDK
|
||||
large-packages: false # apt takes too long
|
||||
swap-storage: false # gradle needs much memory
|
||||
|
||||
- name: Restore AVD
|
||||
id: restore-avd
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
# Enable virtualization for Android emulator
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Cache AVD
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.config/.android/avd
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
- name: Instrumented tests
|
||||
run: ./gradlew core:virtualDebugAndroidTest app:virtualOseDebugAndroidTest
|
||||
|
||||
- name: Run device tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck
|
||||
- name: Cache AVD
|
||||
uses: actions/cache/save@v5
|
||||
if: steps.restore-avd.outputs.cache-hit != 'true'
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,10 +16,6 @@
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
|
||||
9
.idea/codeStyles/Project.xml
generated
Normal file
9
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<option name="RIGHT_MARGIN" value="180" />
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/copyright/LICENSE.xml
generated
Normal file
6
.idea/copyright/LICENSE.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
|
||||
<option name="myName" value="LICENSE" />
|
||||
</copyright>
|
||||
</component>
|
||||
3
.idea/copyright/profiles_settings.xml
generated
Normal file
3
.idea/copyright/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="LICENSE" />
|
||||
</component>
|
||||
30
.tx/config
30
.tx/config
@@ -1,30 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
|
||||
|
||||
[o:bitfireAT:p:davx5:r:app]
|
||||
file_filter = app/src/main/res/values-<lang>/strings.xml
|
||||
source_file = app/src/main/res/values/strings.xml
|
||||
source_lang = en
|
||||
type = ANDROID
|
||||
minimum_perc = 20
|
||||
resource_name = App strings (all flavors)
|
||||
|
||||
|
||||
# Attention: fastlane directories are like "en-us", not "en-rUS"!
|
||||
|
||||
[o:bitfireAT:p:davx5:r:metadata-short-description]
|
||||
file_filter = fastlane/metadata/android/<lang>/short_description.txt
|
||||
source_file = fastlane/metadata/android/en-US/short_description.txt
|
||||
source_lang = en
|
||||
type = TXT
|
||||
minimum_perc = 100
|
||||
resource_name = Metadata: short description
|
||||
|
||||
[o:bitfireAT:p:davx5:r:metadata-full-description]
|
||||
file_filter = fastlane/metadata/android/<lang>/full_description.txt
|
||||
source_file = fastlane/metadata/android/en-US/full_description.txt
|
||||
source_lang = en
|
||||
type = TXT
|
||||
minimum_perc = 100
|
||||
resource_name = Metadata: full description
|
||||
14
AUTHORS
14
AUTHORS
@@ -1,11 +1,7 @@
|
||||
# This is the list of significant contributors to DAVx5.
|
||||
#
|
||||
# This does not necessarily list everyone who has contributed work.
|
||||
# To see the full list of contributors, see the revision history in
|
||||
# source control.
|
||||
You can view the list of people who have contributed to the code base in the version control history:
|
||||
https://github.com/bitfireAT/davx5-ose/graphs/contributors
|
||||
|
||||
Ricki Hirner (bitfire.at)
|
||||
Bernhard Stockmann (bitfire.at)
|
||||
Translators are not mentioned in the history explicitly.
|
||||
The list of translators can be found in the About screen.
|
||||
|
||||
Sunik Kupfer (bitfire.at)
|
||||
Patrick Lang (techbee.at)
|
||||
Every contribution is welcome. There are many other forms of contributing besides writing code!
|
||||
|
||||
@@ -14,24 +14,11 @@ If you send us a pull request, our CLA bot will ask you to sign the
|
||||
Contributor's License Agreement so that we can use your contribution.
|
||||
|
||||
|
||||
# Copyright
|
||||
# Copyright notice
|
||||
|
||||
Make sure that every file that contains significant work (at least every code file)
|
||||
starts with the copyright header:
|
||||
|
||||
```
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
```
|
||||
|
||||
You can set this in Android Studio:
|
||||
|
||||
1. Settings / Editor / Copyright / Copyright Profiles
|
||||
2. Paste the text above (without the stars).
|
||||
3. Set Formatting so that the preview exactly looks like above; one blank line after the block.
|
||||
4. Set this copyright profile as the default profile for the project.
|
||||
5. Apply copyright: right-click in file tree / Update copyright.
|
||||
starts with the copyright header. Android Studio should do so automatically because the
|
||||
configuration is stored in the repository (`.idea/copyright`).
|
||||
|
||||
|
||||
# Style guide
|
||||
@@ -110,8 +97,3 @@ Test classes should be in the appropriate directory (see existing tests) and in
|
||||
tested class. Tests are usually be named like `methodToBeTested_Condition()`, see
|
||||
[Test apps on Android](https://developer.android.com/training/testing/).
|
||||
|
||||
|
||||
# Authors
|
||||
|
||||
If you make significant contributions, feel free to add yourself to the [AUTHORS file](AUTHORS).
|
||||
|
||||
|
||||
17
README.md
17
README.md
@@ -1,9 +1,9 @@
|
||||
|
||||
[](https://www.davx5.com/)
|
||||
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||
[](https://fosstodon.org/@davx5app)
|
||||
[](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml)
|
||||
[](https://www.davx5.com/)
|
||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
DAVx⁵
|
||||
========
|
||||
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵, including a list of services it has been tested with.
|
||||
> [!IMPORTANT]
|
||||
> Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
> comprehensive information about DAVx⁵, including a list of services it has been tested with,
|
||||
> a manual and FAQ.
|
||||
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
@@ -26,8 +28,7 @@ Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://github.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4jvm](https://github.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework
|
||||
* [ical4android](https://github.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://github.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
* [synctools](https://github.com/bitfireAT/synctools) – iCalendar/vCard/Tasks processing and content provider access
|
||||
|
||||
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
|
||||
or [purchasing it](https://www.davx5.com/download).**
|
||||
|
||||
126
app-ose/build.gradle.kts
Normal file
126
app-ose/build.gradle.kts
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 36 // Android 16
|
||||
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 405090005
|
||||
versionName = "4.5.9"
|
||||
|
||||
//base.archivesName = "davx5-$versionCode-$versionName"
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
// Java namespace for our classes (not to be confused with Android package ID)
|
||||
namespace = "com.davx5.ose"
|
||||
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("ose") {
|
||||
dimension = "distribution"
|
||||
versionNameSuffix = "-ose"
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules-release.pro")
|
||||
|
||||
isShrinkResources = true
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("bitfire") {
|
||||
storeFile = file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null")
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("virtual") {
|
||||
device = "Pixel 3"
|
||||
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
|
||||
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
|
||||
apiLevel = 34
|
||||
systemImageSource = "aosp-atd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// include core module
|
||||
implementation(project(":core"))
|
||||
|
||||
// Kotlin / Android
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android.base)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
|
||||
// support libs
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.base)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.work.base)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
||||
|
||||
// own libraries
|
||||
implementation(libs.bitfire.cert4android)
|
||||
|
||||
// third-party libs
|
||||
implementation(libs.guava)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.openid.appauth)
|
||||
}
|
||||
11
app-ose/src/main/AndroidManifest.xml
Normal file
11
app-ose/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<application android:name=".App"/>
|
||||
|
||||
</manifest>
|
||||
@@ -2,18 +2,19 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package com.davx5.ose
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.di.scope.DefaultDispatcher
|
||||
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.CoroutineDispatcher
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Logger
|
||||
@@ -26,11 +27,15 @@ class App: Application(), Configuration.Provider {
|
||||
lateinit var logger: Logger
|
||||
|
||||
/**
|
||||
* Creates the [LogManager] singleton and thus initializes logging.
|
||||
* Creates the [at.bitfire.davdroid.log.LogManager] singleton and thus initializes logging.
|
||||
*/
|
||||
@Inject
|
||||
lateinit var logManager: LogManager
|
||||
|
||||
@Inject
|
||||
@DefaultDispatcher
|
||||
lateinit var defaultDispatcher: CoroutineDispatcher
|
||||
|
||||
@Inject
|
||||
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
|
||||
|
||||
@@ -60,9 +65,9 @@ class App: Application(), Configuration.Provider {
|
||||
|
||||
// don't block UI for some background checks
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
GlobalScope.launch(defaultDispatcher) {
|
||||
// clean up orphaned accounts in DB from time to time
|
||||
AccountsCleanupWorker.enable(this@App)
|
||||
AccountsCleanupWorker.Companion.enable(this@App)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this@App)
|
||||
@@ -17,7 +17,7 @@
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data
|
||||
tools:ignore="AppLinkUrlError"
|
||||
android:scheme="at.bitfire.davdroid"
|
||||
android:scheme="${applicationId}"
|
||||
android:path="/oauth2/redirect"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
@@ -1,8 +1,8 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package com.davx5.ose
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import at.bitfire.davdroid.di.scope.DarkColorScheme
|
||||
import at.bitfire.davdroid.di.scope.LightColorScheme
|
||||
import at.bitfire.davdroid.ui.OseTheme
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class ColorSchemesModule {
|
||||
|
||||
@Provides
|
||||
@LightColorScheme
|
||||
fun lightColorScheme(): ColorScheme = OseTheme.lightScheme
|
||||
|
||||
@Provides
|
||||
@DarkColorScheme
|
||||
fun darkColorScheme(): ColorScheme = OseTheme.darkScheme
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package com.davx5.ose.di
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.cert4android.CustomCertStore
|
||||
import at.bitfire.cert4android.SettingsProvider
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* cert4android integration module
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CustomCertManagerModule {
|
||||
|
||||
@Provides
|
||||
fun customCertStore(@ApplicationContext context: Context): Optional<CustomCertStore> =
|
||||
Optional.of(CustomCertStore.getInstance(context))
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun customCertManager(
|
||||
customCertStore: Optional<CustomCertStore>,
|
||||
settings: SettingsManager
|
||||
): Optional<CustomCertManager> =
|
||||
Optional.of(
|
||||
CustomCertManager(
|
||||
certStore = customCertStore.get(),
|
||||
settings = object : SettingsProvider {
|
||||
|
||||
override val appInForeground: Boolean
|
||||
get() = ForegroundTracker.inForeground.value
|
||||
|
||||
override val trustSystemCerts: Boolean
|
||||
get() = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
|
||||
}
|
||||
))
|
||||
|
||||
@Provides
|
||||
@Reusable
|
||||
fun customHostnameVerifier(
|
||||
customCertManager: Optional<CustomCertManager>
|
||||
): Optional<CustomCertManager.HostnameVerifier> =
|
||||
Optional.of(customCertManager.get().HostnameVerifier(OkHostnameVerifier))
|
||||
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package com.davx5.ose.di
|
||||
|
||||
import at.bitfire.davdroid.ui.AboutActivity
|
||||
import at.bitfire.davdroid.ui.AccountsDrawerHandler
|
||||
import at.bitfire.davdroid.ui.OpenSourceLicenseInfoProvider
|
||||
import at.bitfire.davdroid.ui.OseAccountsDrawerHandler
|
||||
import at.bitfire.davdroid.ui.about.AboutActivity
|
||||
import at.bitfire.davdroid.ui.intro.IntroPageFactory
|
||||
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
|
||||
import com.davx5.ose.ui.about.OpenSourceLicenseInfoProvider
|
||||
import com.davx5.ose.ui.intro.OseIntroPageFactory
|
||||
import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -18,7 +19,7 @@ import dagger.hilt.android.components.ActivityComponent
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
interface OseFlavorModules {
|
||||
interface OseModules {
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityComponent::class)
|
||||
@@ -2,9 +2,9 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
package com.davx5.ose.ui.about
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.text.Spanned
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -14,15 +14,18 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.di.scope.IoDispatcher
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.about.AboutActivity
|
||||
import com.google.common.io.CharStreams
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLicenseInfoProvider {
|
||||
|
||||
@@ -40,13 +43,16 @@ class OpenSourceLicenseInfoProvider @Inject constructor(): AboutActivity.AppLice
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class Model @Inject constructor(app: Application): AndroidViewModel(app) {
|
||||
class Model @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
|
||||
): ViewModel() {
|
||||
|
||||
var gpl by mutableStateOf<Spanned?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
app.resources.assets.open("gplv3.html").use { inputStream ->
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
context.resources.assets.open("gplv3.html").use { inputStream ->
|
||||
val raw = CharStreams.toString(inputStream.bufferedReader())
|
||||
gpl = HtmlCompat.fromHtml(raw, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package com.davx5.ose.ui.intro
|
||||
|
||||
import at.bitfire.davdroid.ui.intro.BackupsPage
|
||||
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage
|
||||
import at.bitfire.davdroid.ui.intro.IntroPageFactory
|
||||
import at.bitfire.davdroid.ui.intro.OpenSourcePage
|
||||
@@ -13,6 +14,7 @@ import at.bitfire.davdroid.ui.intro.WelcomePage
|
||||
import javax.inject.Inject
|
||||
|
||||
class OseIntroPageFactory @Inject constructor(
|
||||
backupsPage: BackupsPage,
|
||||
batteryOptimizationsPage: BatteryOptimizationsPage,
|
||||
openSourcePage: OpenSourcePage,
|
||||
permissionsIntroPage: PermissionsIntroPage,
|
||||
@@ -24,6 +26,7 @@ class OseIntroPageFactory @Inject constructor(
|
||||
tasksIntroPage,
|
||||
permissionsIntroPage,
|
||||
batteryOptimizationsPage,
|
||||
backupsPage,
|
||||
openSourcePage
|
||||
)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
|
||||
# R8 usage for DAVx⁵:
|
||||
# shrinking yes (only in release builds)
|
||||
# optimization yes (on by R8 defaults)
|
||||
# full-mode no (see gradle.properties)
|
||||
# obfuscation no (open-source)
|
||||
|
||||
-dontobfuscate
|
||||
-printusage build/reports/r8-usage.txt
|
||||
|
||||
# ez-vcard: keep all vCard properties/parameters (used via reflection)
|
||||
-keep class ezvcard.io.scribe.** { *; }
|
||||
-keep class ezvcard.property.** { *; }
|
||||
-keep class ezvcard.parameter.** { *; }
|
||||
|
||||
# ical4j: keep all iCalendar properties/parameters (used via reflection)
|
||||
-keep class net.fortuna.ical4j.** { *; }
|
||||
|
||||
# XmlPullParser
|
||||
-keep class org.xmlpull.** { *; }
|
||||
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
|
||||
# AGP 8.2 and 8.3 seem to remove this class, but ezvcard.io uses it. See https://github.com/bitfireAT/davx5/issues/499
|
||||
-keep class javax.xml.namespace.QName { *; }
|
||||
|
||||
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
|
||||
-keepclassmembers,allowoptimization enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
# Additional rules which are now required since missing classes can't be ignored in R8 anymore.
|
||||
# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning]
|
||||
-dontwarn com.android.org.conscrypt.SSLParametersImpl
|
||||
-dontwarn com.github.erosb.jsonsKema.** # ical4j
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
-dontwarn com.sun.jna.** # dnsjava
|
||||
-dontwarn groovy.**
|
||||
-dontwarn java.beans.Transient
|
||||
-dontwarn javax.cache.** # ical4j
|
||||
-dontwarn javax.naming.NamingException # dnsjava
|
||||
-dontwarn javax.naming.directory.** # dnsjava
|
||||
-dontwarn junit.textui.TestRunner
|
||||
-dontwarn lombok.** # dnsjava
|
||||
-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl
|
||||
-dontwarn org.bouncycastle.jsse.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.joda.**
|
||||
-dontwarn org.jparsec.** # ical4j
|
||||
-dontwarn org.json.*
|
||||
-dontwarn org.jsoup.**
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider # dnsjava
|
||||
-dontwarn org.xmlpull.**
|
||||
-dontwarn sun.net.spi.nameservice.NameService
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
1
app/src/.gitignore
vendored
1
app/src/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
espressoTest
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<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>
|
||||
@@ -1,20 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Dav4jvm {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
|
||||
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
|
||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
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 java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* JUnit ClassRule which initializes the AOSP CalendarProvider.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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(): ExternalResource() {
|
||||
|
||||
companion object {
|
||||
|
||||
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 before() {
|
||||
if (!isInitialized) {
|
||||
logger.info("Initializing calendar provider")
|
||||
if (Build.VERSION.SDK_INT < 31)
|
||||
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
|
||||
assertNotNull("Couldn't acquire calendar provider", client)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,48 +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.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
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class OkhttpClientTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testIcloudWithSettings() {
|
||||
val client = HttpClient.Builder(context).build()
|
||||
client.okHttpClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build())
|
||||
.execute()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
|
||||
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 = [PushRegistrationWorkerManager.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>
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AppDatabaseMigrationsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used for testing the migration process between two versions.
|
||||
*
|
||||
* @param fromVersion The version from which to start testing
|
||||
* @param toVersion The target version to test
|
||||
* @param prepare Callback to prepare the database. Will be run with database schema in version [fromVersion].
|
||||
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
|
||||
*/
|
||||
private fun testMigration(
|
||||
fromVersion: Int,
|
||||
toVersion: Int,
|
||||
prepare: (SupportSQLiteDatabase) -> Unit,
|
||||
validate: (SupportSQLiteDatabase) -> Unit
|
||||
) {
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.getAutoMigrationSpecs(context),
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
// Prepare the database with the initial version.
|
||||
helper.createDatabase(TEST_DB, version = fromVersion).apply {
|
||||
prepare(this)
|
||||
close()
|
||||
}
|
||||
|
||||
// Re-open the database with the new version and provide all the migrations.
|
||||
val db = helper.runMigrationsAndValidate(
|
||||
name = TEST_DB,
|
||||
version = toVersion,
|
||||
validateDroppedTables = true,
|
||||
migrations = AppDatabase.manualMigrations
|
||||
)
|
||||
|
||||
validate(db)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
fun migrate15To16_WithTimeZone() {
|
||||
testMigration(
|
||||
fromVersion = 15,
|
||||
toVersion = 16,
|
||||
prepare = { db ->
|
||||
val minimalVTimezone = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
END:VTIMEZONE
|
||||
END:VCALENDAR
|
||||
""".trimIndent()
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, minimalVTimezone)
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals("America/New_York", cursor.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
fun migrate15To16_WithTimeZone_Unparseable() {
|
||||
testMigration(
|
||||
fromVersion = 15,
|
||||
toVersion = 16,
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, "Some Garbage Content")
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertNull(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = 34)
|
||||
fun migrate15To16_WithoutTimezone() {
|
||||
testMigration(
|
||||
fromVersion = 15,
|
||||
toVersion = 16,
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false)
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertNull(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val TEST_DB = "test"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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.Lazy
|
||||
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,
|
||||
object : Lazy<Set<DavCollectionRepository.OnChangeListener>> {
|
||||
override fun get(): Set<DavCollectionRepository.OnChangeListener> {
|
||||
return 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavHomeSetRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var repository: DavHomeSetRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val serviceId = createTestService()
|
||||
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.apply { id = 1L }, repository.getById(1L))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.apply { id = 1L }, repository.getById(1L))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = repository.insertOrUpdateByUrl(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
// should delete row with given primary key (id)
|
||||
val serviceId = createTestService()
|
||||
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = repository.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, repository.getById(1L))
|
||||
|
||||
repository.delete(entry1)
|
||||
assertEquals(null, repository.getById(1L))
|
||||
}
|
||||
|
||||
|
||||
private fun createTestService() : Long {
|
||||
val service = Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
return serviceRepository.insertOrReplace(service)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.SpyK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookStoreTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
val context: Context = mockk(relaxed = true) {
|
||||
every { getString(R.string.account_type_address_book) } returns "com.bitfire.davdroid.addressbook"
|
||||
}
|
||||
// val account = Account("MrRobert@example.com", "com.bitfire.davdroid.addressbook")
|
||||
val account: Account = mockk(relaxed = true) {
|
||||
// every { name } returns "MrRobert@example.com"
|
||||
// every { type } returns "com.bitfire.davdroid.addressbook"
|
||||
}
|
||||
val provider = mockk<ContentProviderClient>(relaxed = true)
|
||||
val addressBook: LocalAddressBook = mockk(relaxed = true) {
|
||||
every { updateSyncFrameworkSettings() } just runs
|
||||
every { addressBookAccount } returns account
|
||||
every { settings } returns LocalAddressBookStore.contactsProviderSettings
|
||||
}
|
||||
|
||||
@SpyK
|
||||
@InjectMockKs
|
||||
var localAddressBookStore = LocalAddressBookStore(
|
||||
collectionRepository = mockk(relaxed = true),
|
||||
context = context,
|
||||
localAddressBookFactory = mockk(relaxed = true) {
|
||||
every { create(account, provider) } returns addressBook
|
||||
},
|
||||
logger = mockk(relaxed = true),
|
||||
serviceRepository = mockk(relaxed = true) {
|
||||
every { get(any<Long>()) } returns null
|
||||
every { get(200) } returns mockk<Service> {
|
||||
every { accountName } returns "MrRobert@example.com"
|
||||
}
|
||||
},
|
||||
settings = mockk(relaxed = true)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingService() {
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 42
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 404
|
||||
}
|
||||
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayName() {
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 42
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 200
|
||||
}
|
||||
val accountName = localAddressBookStore.accountName(collection)
|
||||
assertEquals("funnyfriends (MrRobert@example.com) #42", accountName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayNameAndService() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 404 // missing service
|
||||
}
|
||||
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_create_createAccountReturnsNull() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
}
|
||||
every { localAddressBookStore.createAccount(any(), any(), any()) } returns null
|
||||
assertEquals(null, localAddressBookStore.create(provider, collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_create_createAccountReturnsAccount() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
}
|
||||
every { localAddressBookStore.createAccount(any(), any(), any()) } returns account
|
||||
every { addressBook.readOnly } returns true
|
||||
val addrBook = localAddressBookStore.create(provider, collection)!!
|
||||
|
||||
verify(exactly = 1) { addressBook.updateSyncFrameworkSettings() }
|
||||
assertEquals(account, addrBook.addressBookAccount)
|
||||
assertEquals(LocalAddressBookStore.contactsProviderSettings, addrBook.settings)
|
||||
assertEquals(true, addrBook.readOnly)
|
||||
|
||||
every { addressBook.readOnly } returns false
|
||||
val addrBook2 = localAddressBookStore.create(provider, collection)!!
|
||||
assertEquals(false, addrBook2.readOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_createAccount_succeeds() {
|
||||
mockkObject(SystemAccountUtils)
|
||||
every { SystemAccountUtils.createAccount(any(), any(), any()) } returns true
|
||||
val account = Account("MrRobert@example.com", "com.bitfire.davdroid.addressbook")
|
||||
val createdAccount: Account = localAddressBookStore.createAccount(
|
||||
"MrRobert@example.com", 42, "https://example.com/addressbook/funnyfriends"
|
||||
)!!
|
||||
verify(exactly = 1) { SystemAccountUtils.createAccount(context, account, any()) }
|
||||
assertEquals(account, createdAccount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests the calculation of read only state is correct
|
||||
*/
|
||||
@Test
|
||||
fun test_shouldBeReadOnly() {
|
||||
val collectionReadOnly = mockk<Collection> { every { readOnly() } returns true }
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false))
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true))
|
||||
|
||||
val collectionNotReadOnly = mockk<Collection> { every { readOnly() } returns false }
|
||||
assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false))
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.LabeledProperty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import ezvcard.property.Telephone
|
||||
import 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
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
LocalTestAddressBook.createAccount(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(addressBook.addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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))
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
|
||||
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
|
||||
|
||||
val group = result.getContact()
|
||||
assertEquals("Test Group", group.displayName)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.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.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()
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpClass() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownClass() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
// Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule)
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.datastore.dataStore
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.Date
|
||||
import net.fortuna.ical4j.model.DateList
|
||||
import net.fortuna.ical4j.model.parameter.Value
|
||||
import net.fortuna.ical4j.model.property.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.UUID
|
||||
|
||||
class LocalEventTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpClass() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDownClass() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_SingleInstance() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_Recurring() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_Recurring_Endless() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without end"
|
||||
rRules.add(RRule("FREQ=DAILY"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_Recurring_LateEnd() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 53 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
else
|
||||
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_Recurring_ManyInstances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 2 years"
|
||||
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!)
|
||||
|
||||
// Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct),
|
||||
// but we are satisfied with either result for now
|
||||
assertTrue(number == 365*2 || number == 365*2+1)
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_RecurringWithExdate() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart(Date("20220120T010203Z"))
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME)))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_RecurringWithExceptions() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T130203Z")
|
||||
summary = "Exception on 3rd day"
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220124T010203Z")
|
||||
dtStart = DtStart("20220122T160203Z")
|
||||
summary = "Exception on 5th day"
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_SingleInstance() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_Recurring() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_Recurring_Endless() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with infinite instances"
|
||||
rRules.add(RRule("FREQ=YEARLY"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_Recurring_LateEnd() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over 22 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
else
|
||||
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_Recurring_ManyInstances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over two years"
|
||||
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
|
||||
365*2 // Android <10: does not include UNTIL (incorrect!)
|
||||
else
|
||||
365*2 + 1, // Android ≥10: includes UNTIL (correct)
|
||||
LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_RecurringWithExceptions() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 6 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=6"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T130203Z")
|
||||
summary = "Exception on 3rd day"
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220124T010203Z")
|
||||
dtStart = DtStart("20220122T160203Z")
|
||||
summary = "Exception on 5th day"
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
calendar.findById(localEvent.id!!)
|
||||
|
||||
assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkEventAsDeleted() {
|
||||
// Create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "A fine event"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
// Delete event
|
||||
LocalEvent.markAsDeleted(provider, account, localEvent.id!!)
|
||||
|
||||
// Get the status of whether the event is deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.DELETED),
|
||||
null,
|
||||
null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NoUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without uid"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage should be the same as file name
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NormalUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with normal uid"
|
||||
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should use the UID for the file name
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
assertEquals(event.uid, fileName)
|
||||
|
||||
// UID in calendar storage should still be set, too
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_UidHasDangerousChars() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with funny uid"
|
||||
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage shouldn't have been changed
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(event.uid, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.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.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.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 {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@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 setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
// clear contacts
|
||||
addressBookGroupsAsCategories.clear()
|
||||
addressBookGroupsAsVCards.clear()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_addPendingMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
// set pending membership of contact1
|
||||
ab.provider!!.update(
|
||||
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
|
||||
ContentValues().apply {
|
||||
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
|
||||
},
|
||||
null, null
|
||||
)
|
||||
|
||||
// pending membership -> contact1 should be added to group
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_removeMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
|
||||
// add contact1 to group
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
// no pending memberships -> membership should be removed
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testClearDirty_addCachedGroupMembership() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert group membership, but no cached group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(GroupMembership.GROUP_ROW_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDirty_removeCachedGroupMembership() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert cached group membership, but no group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(CachedGroupMembership.GROUP_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
|
||||
// cached group membership should be gone
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkMembersDirty() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
assertEquals(0, ab.findDirty().size)
|
||||
group.markMembersDirty()
|
||||
assertEquals(contact1.id, ab.findDirty().first().id)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
val group = newGroup()
|
||||
assertNull(group.getContact().uid)
|
||||
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
}, null, null, 0
|
||||
).apply {
|
||||
add()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.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.Optional
|
||||
import java.util.logging.Logger
|
||||
|
||||
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(
|
||||
_addressBookAccount = ACCOUNT,
|
||||
provider = provider,
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
context = context,
|
||||
dirtyVerifier = Optional.empty(),
|
||||
logger = logger,
|
||||
serviceRepository = serviceRepository
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
}
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = false
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
|
||||
fun clear() {
|
||||
for (contact in queryContacts(null, null))
|
||||
contact.delete()
|
||||
for (group in queryGroups(null, null))
|
||||
group.delete()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import 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.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
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 io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher(logger)
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(context).build()
|
||||
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
|
||||
|
||||
// Check home sets have been saved to database
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
|
||||
assertEquals(1, db.homeSetDao().getByService(service.id).size)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
Collection(
|
||||
1,
|
||||
service.id,
|
||||
homesetId,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().getByService(service.id).first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save homeset in DB - which is empty (zero address books) on the serverside
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId, // part of above home set
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomelessCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_deletesInaccessibleCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB - it is also inaccessible
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshPrincipals
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals("Mr. Wobbles", principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
// Others
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
mockkObject(settings) {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
}
|
||||
|
||||
// Test helpers and dependencies
|
||||
|
||||
private fun createTestService(serviceType: String) : Service? {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)
|
||||
}
|
||||
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
"<current-user-principal>" +
|
||||
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
|
||||
"</current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
"<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
var responseBody = ""
|
||||
var responseCode = 207
|
||||
when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
|
||||
responseCode = 404
|
||||
|
||||
else ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
logger.info("Response: $responseBody")
|
||||
return MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setBody(responseBody)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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)!!
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* 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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="app_name">Davx5Test</string>
|
||||
<string name="account_type_test">at.bitfire.davdroid.test</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,5 +0,0 @@
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/account_type_test"
|
||||
android:icon="@android:drawable/star_on"
|
||||
android:smallIcon="@android:drawable/star_on"
|
||||
android:label="Test Account" />
|
||||
@@ -1 +0,0 @@
|
||||
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces","pikamoku"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
|
||||
*/
|
||||
object Constants {
|
||||
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
|
||||
val HOMEPAGE_URL = "https://www.davx5.com".toUri()
|
||||
const val HOMEPAGE_PATH_FAQ = "faq"
|
||||
const val HOMEPAGE_PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected"
|
||||
const val HOMEPAGE_PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission"
|
||||
const val HOMEPAGE_PATH_OPEN_SOURCE = "donate"
|
||||
const val HOMEPAGE_PATH_PRIVACY = "privacy"
|
||||
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
|
||||
|
||||
val MANUAL_URL = "https://manual.davx5.com".toUri()
|
||||
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
|
||||
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
|
||||
const val MANUAL_PATH_SETTINGS = "settings.html"
|
||||
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
|
||||
const val MANUAL_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
|
||||
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
|
||||
|
||||
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
|
||||
|
||||
val FEDIVERSE_HANDLE = "@davx5app@fosstodon.org"
|
||||
val FEDIVERSE_URL = "https://fosstodon.org/@davx5app".toUri()
|
||||
|
||||
/**
|
||||
* Appends query parameters for anonymized usage statistics (app ID, version).
|
||||
* Can be used by the called Website to get an idea of which versions etc. are currently used.
|
||||
*
|
||||
* @param context optional info about from where the URL was opened (like a specific Activity)
|
||||
*/
|
||||
fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder {
|
||||
appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
|
||||
appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
|
||||
|
||||
if (context != null)
|
||||
appendQueryParameter("pk_kwd", context)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
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")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class,
|
||||
Principal::class,
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 16, 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 = 13, to = 14),
|
||||
AutoMigration(from = 14, to = 15),
|
||||
AutoMigration(from = 15, to = 16, spec = AppDatabase.AutoMigration15_16::class)
|
||||
])
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun appDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
notificationRegistry: NotificationRegistry
|
||||
): AppDatabase = Room
|
||||
.databaseBuilder(context, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*manualMigrations)
|
||||
.apply {
|
||||
for (spec in getAutoMigrationSpecs(context))
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
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.removeAccountExplicitly(account)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
// auto migrations
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@RenameColumn(tableName = "collection", fromColumnName = "timezone", toColumnName = "timezoneId")
|
||||
class AutoMigration15_16: AutoMigrationSpec {
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
// The timezone column has been renamed to timezoneId, but still contains the VTIMEZONE.
|
||||
// So we need to parse the VTIMEZONE, extract the timezone ID and save it back.
|
||||
db.query("SELECT id, timezoneId FROM collection").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id: Long = cursor.getLong(0)
|
||||
val timezoneDef: String = cursor.getString(1) ?: continue
|
||||
val vTimeZone = DateUtils.parseVTimeZone(timezoneDef)
|
||||
val timezoneId = vTimeZone?.timeZoneId?.value
|
||||
db.execSQL("UPDATE collection SET timezoneId=? WHERE id=?", arrayOf(timezoneId, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@DeleteColumn(tableName = "collection", columnName = "owner")
|
||||
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
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)
|
||||
RefreshCollectionsWorker.enqueue(context, serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// automatic migrations
|
||||
|
||||
fun getAutoMigrationSpecs(context: Context) = listOf(
|
||||
AutoMigration11_12(context),
|
||||
AutoMigration15_16()
|
||||
)
|
||||
|
||||
// manual migrations
|
||||
|
||||
val manualMigrations: Array<Migration> = arrayOf(
|
||||
Migration(8, 9) { db ->
|
||||
db.execSQL("CREATE TABLE syncstats (" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
|
||||
"authority TEXT NOT NULL," +
|
||||
"lastSync INTEGER NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
|
||||
|
||||
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
|
||||
},
|
||||
|
||||
Migration(7, 8) { db ->
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
|
||||
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
|
||||
},
|
||||
|
||||
Migration(6, 7) { db ->
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
},
|
||||
|
||||
Migration(5, 6) { db ->
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
},
|
||||
|
||||
Migration(4, 5) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
},
|
||||
|
||||
Migration(3, 4) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
},
|
||||
|
||||
Migration(2, 3) { db ->
|
||||
// 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.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
},
|
||||
|
||||
Migration(1, 2) { db ->
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
// DAOs
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
abstract fun principalDao(): PrincipalDao
|
||||
abstract fun syncStatsDao(): SyncStatsDao
|
||||
abstract fun webDavDocumentDao(): WebDavDocumentDao
|
||||
abstract fun webDavMountDao(): WebDavMountDao
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
fun dump(writer: Writer, ignoreTables: Array<String>) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val tableName = cursorTables.getString(0)
|
||||
if (ignoreTables.contains(tableName)) {
|
||||
writer.append("$tableName: ")
|
||||
db.query("SELECT COUNT(*) FROM $tableName").use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n")
|
||||
}
|
||||
} else {
|
||||
writer.append("$tableName\n")
|
||||
db.query("SELECT * FROM $tableName").use { cursor ->
|
||||
val table = TextTable(*cursor.columnNames)
|
||||
val cols = cursor.columnCount
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
|
||||
table.addLine(*values)
|
||||
}
|
||||
writer.append(table.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
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
|
||||
|
||||
/**
|
||||
* Logging handler that logs to Android logcat.
|
||||
*/
|
||||
internal class LogcatHandler: Handler() {
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
}
|
||||
|
||||
override fun publish(r: LogRecord) {
|
||||
val level = r.level.intValue()
|
||||
val text = formatter.format(r)
|
||||
|
||||
// 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,111 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
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 val withTime: Boolean,
|
||||
private val withSource: Boolean,
|
||||
private val padSource: Int = 30,
|
||||
private val withException: Boolean,
|
||||
private val lineSeparator: String?
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* 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 (withTime)
|
||||
builder .append(timeFormat.format(Date(r.millis)))
|
||||
.append(" ").append(r.threadID).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(truncate(r.message))
|
||||
|
||||
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 + 1).append(" = ")
|
||||
|
||||
val valStr = if (param == null)
|
||||
"(null)"
|
||||
else
|
||||
truncate(param.toString())
|
||||
builder.append(valStr)
|
||||
}
|
||||
}
|
||||
|
||||
if (lineSeparator != null)
|
||||
builder.append(lineSeparator)
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun truncate(s: String) =
|
||||
Ascii.truncate(s, MAX_LENGTH, "[…]")
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Sends an OAuth Bearer token authorization as described in RFC 6750.
|
||||
*/
|
||||
class BearerAuthInterceptor(
|
||||
private val accessToken: String
|
||||
): Interceptor {
|
||||
|
||||
companion object {
|
||||
|
||||
val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
|
||||
return runBlocking {
|
||||
val accessTokenFuture = CompletableDeferred<String>()
|
||||
|
||||
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
|
||||
if (accessToken != null) {
|
||||
// persist updated AuthState
|
||||
callback?.onUpdate(authState)
|
||||
|
||||
// emit access token
|
||||
accessTokenFuture.complete(accessToken)
|
||||
}
|
||||
else {
|
||||
logger.log(Level.WARNING, "Couldn't obtain access token", ex)
|
||||
accessTokenFuture.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// return value
|
||||
try {
|
||||
BearerAuthInterceptor(accessTokenFuture.await())
|
||||
} catch (ignored: CancellationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
logger.finer("Authenticating request with access token")
|
||||
val rq = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
return chain.proceed(rq)
|
||||
}
|
||||
|
||||
|
||||
fun interface AuthStateUpdateCallback {
|
||||
fun onUpdate(authState: AuthState)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationRequest
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.AuthorizationServiceConfiguration
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import net.openid.appauth.TokenResponse
|
||||
import java.net.URI
|
||||
import java.util.logging.Logger
|
||||
|
||||
class GoogleLogin(
|
||||
val authService: AuthorizationService
|
||||
) {
|
||||
|
||||
private val logger: Logger = Logger.getGlobal()
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// davx5integration@gmail.com (for davx5-ose)
|
||||
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
|
||||
|
||||
private val SCOPES = arrayOf(
|
||||
"https://www.googleapis.com/auth/calendar", // CalDAV
|
||||
"https://www.googleapis.com/auth/carddav" // CardDAV
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
|
||||
* _calid_ of the primary calendar is the account name.
|
||||
*
|
||||
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
|
||||
* calendars.
|
||||
*/
|
||||
fun googleBaseUri(googleAccount: String): URI =
|
||||
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
|
||||
|
||||
private val serviceConfig = AuthorizationServiceConfiguration(
|
||||
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
|
||||
Uri.parse("https://oauth2.googleapis.com/token")
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
|
||||
val builder = AuthorizationRequest.Builder(
|
||||
GoogleLogin.serviceConfig,
|
||||
customClientId ?: CLIENT_ID,
|
||||
ResponseTypeValues.CODE,
|
||||
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect")
|
||||
)
|
||||
return builder
|
||||
.setScopes(*SCOPES)
|
||||
.setLoginHint(email)
|
||||
.setUiLocales(locale)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun authenticate(authResponse: AuthorizationResponse): Credentials {
|
||||
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
|
||||
val credentials = CompletableDeferred<Credentials>()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
|
||||
logger.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
|
||||
|
||||
if (tokenResponse != null) {
|
||||
// success, save authState (= refresh token)
|
||||
authState.update(tokenResponse, refreshTokenException)
|
||||
credentials.complete(Credentials(authState = authState))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return credentials.await()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import 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
|
||||
import okhttp3.Cache
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
|
||||
class HttpClient @AssistedInject constructor(
|
||||
@Assisted val okHttpClient: OkHttpClient,
|
||||
@Assisted private var authService: AuthorizationService? = null,
|
||||
val settingsManager: SettingsManager
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** max. size of disk cache (10 MB) */
|
||||
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
|
||||
|
||||
/** Base Builder to build all clients from. Use rarely; [OkHttpClient]s should
|
||||
* be reused as much as possible. */
|
||||
fun baseBuilder() =
|
||||
OkHttpClient.Builder()
|
||||
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
|
||||
// traffic within a minute, a sync will be cancelled.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(
|
||||
45,
|
||||
TimeUnit.SECONDS
|
||||
) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
|
||||
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
|
||||
.connectionSpecs(
|
||||
listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.COMPATIBLE_TLS
|
||||
)
|
||||
)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addInterceptor(UserAgentInterceptor)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(okHttpClient: OkHttpClient, authService: AuthorizationService?): HttpClient
|
||||
}
|
||||
|
||||
|
||||
override fun close() {
|
||||
authService?.dispose()
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
|
||||
|
||||
class Builder(
|
||||
val context: Context,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: Logger = Logger.getGlobal(),
|
||||
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface HttpClientBuilderEntryPoint {
|
||||
fun authorizationService(): AuthorizationService
|
||||
fun httpClientFactory(): Factory
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
private val entryPoint = EntryPointAccessors.fromApplication<HttpClientBuilderEntryPoint>(context)
|
||||
|
||||
fun interface CertManagerProducer {
|
||||
fun certManager(): CustomCertManager
|
||||
}
|
||||
|
||||
private var appInForeground: MutableStateFlow<Boolean>? =
|
||||
MutableStateFlow(false)
|
||||
private var authService: AuthorizationService? = null
|
||||
private var certManagerProducer: CertManagerProducer? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var offerCompression: Boolean = false
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar? = MemoryCookieStore()
|
||||
|
||||
private val orig = baseBuilder()
|
||||
|
||||
init {
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.level = loggerLevel
|
||||
orig.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
val settings = entryPoint.settingsManager()
|
||||
|
||||
// custom proxy support
|
||||
try {
|
||||
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
|
||||
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
|
||||
// we set our own proxy
|
||||
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
|
||||
InetSocketAddress(
|
||||
settings.getString(Settings.PROXY_HOST),
|
||||
settings.getInt(Settings.PROXY_PORT)
|
||||
)
|
||||
}
|
||||
val proxy =
|
||||
when (proxyTypeValue) {
|
||||
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
|
||||
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
|
||||
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
|
||||
else -> throw IllegalArgumentException("Invalid proxy type")
|
||||
}
|
||||
orig.proxy(proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
customCertManager {
|
||||
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
|
||||
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
CustomCertManager(context, trustSystemCerts, appInForeground)
|
||||
}
|
||||
|
||||
// use account settings for authentication and cookies
|
||||
if (accountSettings != null)
|
||||
addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState ->
|
||||
accountSettings.credentials(Credentials(authState = authState))
|
||||
})
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials?) : this(context) {
|
||||
if (credentials != null)
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
|
||||
if (credentials.username != null && credentials.password != null) {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive)
|
||||
orig.addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
credentials.authState?.let { authState ->
|
||||
val newAuthService = entryPoint.authorizationService()
|
||||
authService = newAuthService
|
||||
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
|
||||
orig.addNetworkInterceptor(bearerAuthInterceptor)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun allowCompression(allow: Boolean): Builder {
|
||||
offerCompression = allow
|
||||
return this
|
||||
}
|
||||
|
||||
fun cookieStore(store: CookieJar?): Builder {
|
||||
cookieStore = store
|
||||
return this
|
||||
}
|
||||
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
orig.followRedirects(follow)
|
||||
return this
|
||||
}
|
||||
|
||||
fun customCertManager(producer: CertManagerProducer) {
|
||||
certManagerProducer = producer
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
appInForeground?.value = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
logger.fine("Using disk cache: $cacheDir")
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): HttpClient {
|
||||
cookieStore?.let {
|
||||
orig.cookieJar(it)
|
||||
}
|
||||
|
||||
if (offerCompression)
|
||||
// offer Brotli and gzip compression
|
||||
orig.addInterceptor(BrotliInterceptor)
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
certificateAlias?.let { alias ->
|
||||
// get provider certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger?.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create KeyManager
|
||||
keyManager = object : X509ExtendedKeyManager() {
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
|
||||
alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
}
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
orig.protocols(listOf(Protocol.HTTP_1_1))
|
||||
}
|
||||
|
||||
if (certManagerProducer != null || keyManager != null) {
|
||||
val manager = certManagerProducer?.certManager()
|
||||
|
||||
val trustManager = manager ?: /* fall back to system default trust manager */
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
.let { factory ->
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}
|
||||
|
||||
val hostnameVerifier =
|
||||
if (manager != null)
|
||||
manager.HostnameVerifier(OkHostnameVerifier)
|
||||
else
|
||||
OkHostnameVerifier
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else null,
|
||||
arrayOf(trustManager),
|
||||
null)
|
||||
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
}
|
||||
|
||||
return entryPoint.httpClientFactory().create(orig.build(), authService = authService)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
object UserAgentInterceptor: Interceptor {
|
||||
|
||||
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
Logger.getGlobal().info("Will set User-Agent: $userAgent")
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.util.withTrailingSlash
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Implements Nextcloud Login Flow v2.
|
||||
*
|
||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
*/
|
||||
class NextcloudLoginFlow(
|
||||
context: Context
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
const val FLOW_V1_PATH = "index.php/login/flow"
|
||||
const val FLOW_V2_PATH = "index.php/login/v2"
|
||||
|
||||
/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
|
||||
const val DAV_PATH = "remote.php/dav"
|
||||
}
|
||||
|
||||
val httpClient = HttpClient.Builder(context)
|
||||
.setForeground(true)
|
||||
.build()
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
// Login flow state
|
||||
var loginUrl: HttpUrl? = null
|
||||
var pollUrl: HttpUrl? = null
|
||||
var token: String? = null
|
||||
|
||||
|
||||
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
|
||||
loginUrl = null
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
|
||||
|
||||
loginUrl = json.getString("login").toHttpUrlOrNull()
|
||||
json.getJSONObject("poll").let { poll ->
|
||||
pollUrl = poll.getString("endpoint").toHttpUrl()
|
||||
token = poll.getString("token")
|
||||
}
|
||||
|
||||
return loginUrl
|
||||
}
|
||||
|
||||
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
|
||||
val path = baseUrl.encodedPath
|
||||
|
||||
if (path.endsWith(FLOW_V2_PATH))
|
||||
// already a Login Flow v2 URL
|
||||
return baseUrl
|
||||
|
||||
if (path.endsWith(FLOW_V1_PATH))
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
return baseUrl.newBuilder()
|
||||
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
|
||||
.build()
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
return baseUrl.newBuilder()
|
||||
.addPathSegments(FLOW_V2_PATH)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val serverUrl = json.getString("server").withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = json.getString("loginName"),
|
||||
password = json.getString("appPassword")
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
|
||||
val postRq = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val response = runInterruptible {
|
||||
httpClient.okHttpClient.newCall(postRq).execute()
|
||||
}
|
||||
|
||||
if (response.code != HttpURLConnection.HTTP_OK)
|
||||
throw HttpException(response)
|
||||
|
||||
response.body?.use { body ->
|
||||
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
|
||||
if (mimeType.type != "application" || mimeType.subtype != "json")
|
||||
throw DavException("Invalid Login Flow response (not JSON)")
|
||||
|
||||
// decode JSON
|
||||
return@withContext JSONObject(body.string())
|
||||
}
|
||||
|
||||
throw DavException("Invalid Login Flow response (no body)")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
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.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
@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) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
logger.info("Running push registration worker")
|
||||
|
||||
try {
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
} catch (_: IOException) {
|
||||
return Result.retry() // retry on I/O errors
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// requested expiration time: 3 days
|
||||
val requestedExpiration = Instant.now() + Duration.ofDays(3)
|
||||
|
||||
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")) {
|
||||
// subscription URL
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) {
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) {
|
||||
text(endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
// requested expiration
|
||||
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "expires")) {
|
||||
text(HttpUtils.formatDate(requestedExpiration))
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||
DavCollection(httpClient, collection.url).post(xml) { response ->
|
||||
if (response.isSuccessful) {
|
||||
val subscriptionUrl = response.header("Location")
|
||||
val expires = response.header("Expires")?.let { expiresDate ->
|
||||
HttpUtils.parseDate(expiresDate)
|
||||
} ?: requestedExpiration
|
||||
collectionRepository.updatePushSubscription(
|
||||
id = collection.id,
|
||||
subscriptionUrl = subscriptionUrl,
|
||||
expires = expires?.epochSecond
|
||||
)
|
||||
} 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()) {
|
||||
val expires = collection.pushSubscriptionExpires
|
||||
// calculate next run time, but use the duplicate interval for safety (times are not exact)
|
||||
val nextRun = Instant.now() + Duration.ofDays(2*PushRegistrationWorkerManager.INTERVAL_DAYS)
|
||||
if (expires != null && expires >= nextRun.epochSecond) {
|
||||
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
|
||||
continue
|
||||
}
|
||||
|
||||
// no existing subscription or expiring soon
|
||||
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(
|
||||
id = collection.id,
|
||||
subscriptionUrl = null,
|
||||
expires = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushRegistrationWorkerManager @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val collectionRepository: DavCollectionRepository,
|
||||
val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
|
||||
*
|
||||
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
|
||||
* A potentially existing worker is replaced, so that the first run should be soon.
|
||||
*
|
||||
* Otherwise, a potentially existing worker is cancelled.
|
||||
*/
|
||||
fun updatePeriodicWorker() {
|
||||
val workerNeeded = runBlocking {
|
||||
collectionRepository.anyPushCapable()
|
||||
}
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (workerNeeded) {
|
||||
logger.info("Enqueuing periodic PushRegistrationWorker")
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
|
||||
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, INTERVAL_DAYS, TimeUnit.DAYS)
|
||||
.setInitialDelay(5, TimeUnit.SECONDS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
logger.info("Cancelling periodic PushRegistrationWorker")
|
||||
workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val UNIQUE_WORK_NAME = "push-registration"
|
||||
const val INTERVAL_DAYS = 1L
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Listener that enqueues a push registration worker when the collection list changes.
|
||||
*/
|
||||
class CollectionsListener @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val workerManager: PushRegistrationWorkerManager
|
||||
): DavCollectionRepository.OnChangeListener {
|
||||
|
||||
override fun onCollectionsChanged() {
|
||||
workerManager.updatePeriodicWorker()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface PushRegistrationWorkerModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class UnifiedPushReceiver: MessagingReceiver() {
|
||||
|
||||
@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 pushRegistrationWorkerManager: PushRegistrationWorkerManager
|
||||
|
||||
@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
|
||||
pushRegistrationWorkerManager.updatePeriodicWorker()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import androidx.room.Transaction
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import javax.inject.Inject
|
||||
|
||||
class DavHomeSetRepository @Inject constructor(
|
||||
db: AppDatabase
|
||||
) {
|
||||
|
||||
val dao = db.homeSetDao()
|
||||
|
||||
fun getAddressBookHomeSetsFlow(account: Account) =
|
||||
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV)
|
||||
|
||||
fun getBindableByServiceFlow(serviceId: Long) = dao.getBindableByServiceFlow(serviceId)
|
||||
|
||||
fun getById(id: Long) = dao.getById(id)
|
||||
|
||||
fun getByService(serviceId: Long) = dao.getByService(serviceId)
|
||||
|
||||
fun getCalendarHomeSetsFlow(account: Account) =
|
||||
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
|
||||
|
||||
|
||||
/**
|
||||
* Tries to insert new row, but updates existing row if already present.
|
||||
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
|
||||
* which will create a new row with incremented ID and thus breaks entity relationships!
|
||||
*
|
||||
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
|
||||
*/
|
||||
@Transaction
|
||||
fun insertOrUpdateByUrl(homeset: HomeSet): Long =
|
||||
dao.getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
|
||||
dao.update(homeset.copy(id = existingHomeset.id))
|
||||
existingHomeset.id
|
||||
} ?: dao.insert(homeset)
|
||||
|
||||
|
||||
fun delete(homeSet: HomeSet) = dao.delete(homeSet)
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
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
|
||||
|
||||
class DavSyncStatsRepository @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val dao = db.syncStatsDao()
|
||||
|
||||
data class LastSynced(
|
||||
val appName: String,
|
||||
val lastSynced: Long
|
||||
)
|
||||
fun getLastSyncedFlow(collectionId: Long): Flow<List<LastSynced>> =
|
||||
dao.getByCollectionIdFlow(collectionId).map { list ->
|
||||
val collator = Collator.getInstance()
|
||||
list.map { stats ->
|
||||
LastSynced(
|
||||
appName = appNameFromAuthority(stats.authority),
|
||||
lastSynced = stats.lastSync
|
||||
)
|
||||
}.sortedWith { a, b ->
|
||||
collator.compare(a.appName, b.appName)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* found.
|
||||
*
|
||||
* @param authority authority to find the application name for (ie "at.techbee.jtx")
|
||||
* @return the application name of authority (ie "jtx Board")
|
||||
*/
|
||||
private fun appNameFromAuthority(authority: String): String {
|
||||
val packageManager = context.packageManager
|
||||
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
|
||||
return try {
|
||||
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
|
||||
if (appInfo != null) {
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} else {
|
||||
logger.warning("Package name ($packageName) not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
logger.warning("Application name not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_URL
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.orEmpty
|
||||
|
||||
class LocalAddressBookStore @Inject constructor(
|
||||
val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext val context: Context,
|
||||
val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
val logger: Logger,
|
||||
val serviceRepository: DavServiceRepository,
|
||||
val settings: SettingsManager
|
||||
): LocalDataStore<LocalAddressBook> {
|
||||
|
||||
/** whether a (usually managed) setting wants all address-books to be read-only **/
|
||||
val forceAllReadOnly: Boolean
|
||||
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
|
||||
|
||||
|
||||
/**
|
||||
* Assembles a name for the address book (account) from its corresponding database [Collection].
|
||||
*
|
||||
* 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 Collection to take info from
|
||||
*/
|
||||
fun accountName(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
|
||||
serviceRepository.get(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
sb.append(" #${info.id}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val name = accountName(fromCollection)
|
||||
val account = createAccount(
|
||||
name = name,
|
||||
id = fromCollection.id,
|
||||
url = fromCollection.url.toString()
|
||||
) ?: return null
|
||||
|
||||
val addressBook = localAddressBookFactory.create(account, provider)
|
||||
|
||||
// update settings
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
addressBook.settings = contactsProviderSettings
|
||||
addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
fun createAccount(name: String, id: Long, url: String): Account? {
|
||||
// create account with collection ID and URL
|
||||
val account = Account(name, context.getString(R.string.account_type_address_book))
|
||||
val userData = Bundle(2).apply {
|
||||
putString(USER_DATA_COLLECTION_ID, id.toString())
|
||||
putString(USER_DATA_URL, url)
|
||||
}
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData)) {
|
||||
logger.warning("Couldn't create address book account: $account")
|
||||
return null
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service ->
|
||||
// get all collections for the CardDAV service
|
||||
collectionRepository.getByService(service.id).mapNotNull { collection ->
|
||||
// and map to a LocalAddressBook, if applicable
|
||||
findByCollection(provider, collection.id)
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private fun findByCollection(provider: ContentProviderClient, id: Long): LocalAddressBook? {
|
||||
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 -> localAddressBookFactory.create(account, provider) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
var currentAccount = localCollection.addressBookAccount
|
||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||
|
||||
// Update the account name
|
||||
val newAccountName = accountName(fromCollection)
|
||||
if (currentAccount.name != newAccountName) {
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
localCollection.renameAccount(newAccountName)
|
||||
currentAccount.name = newAccountName
|
||||
}
|
||||
|
||||
// Update the account user data
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setAndVerifyUserData(currentAccount, USER_DATA_COLLECTION_ID, fromCollection.id.toString())
|
||||
accountManager.setAndVerifyUserData(currentAccount, USER_DATA_URL, fromCollection.url.toString())
|
||||
|
||||
// Set contacts provider settings
|
||||
localCollection.settings = contactsProviderSettings
|
||||
|
||||
// Update force read only
|
||||
val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
|
||||
if (nowReadOnly != localCollection.readOnly) {
|
||||
logger.info("Address book has changed to read-only = $nowReadOnly")
|
||||
localCollection.readOnly = nowReadOnly
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
localCollection.updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalAddressBook) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a [LocalAddressBook] based on its corresponding database collection.
|
||||
*
|
||||
* @param id [Collection.id] to look for
|
||||
*/
|
||||
fun deleteByCollectionId(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)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Contacts Provider Settings (equal for every address book)
|
||||
*/
|
||||
val contactsProviderSettings
|
||||
get() = ContentValues(2).apply {
|
||||
// SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable.
|
||||
put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
|
||||
// UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems).
|
||||
put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the address book should be set to read-only.
|
||||
*
|
||||
* @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information
|
||||
* @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege)
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean =
|
||||
info.readOnly() || forceAllReadOnly
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
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
|
||||
): 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()
|
||||
|
||||
}
|
||||
|
||||
override val collectionUrl: String?
|
||||
get() = name
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = displayName ?: id.toString()
|
||||
|
||||
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
|
||||
override val readOnly
|
||||
get() = accessLevel <= Calendars.CAL_ACCESS_READ
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return SyncState.fromString(cursor.getString(0))
|
||||
else
|
||||
null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_SYNC_STATE, state.toString())
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
override fun populate(info: ContentValues) {
|
||||
super.populate(info)
|
||||
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
|
||||
}
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
try {
|
||||
val event = requireNotNull(localEvent.event)
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = localEvent.weAreOrganizer
|
||||
|
||||
val sequence = event.sequence
|
||||
if (sequence == null)
|
||||
// sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
dirty += localEvent
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var deleted = 0
|
||||
// list all non-dirty events with the given flags and delete every row + its exceptions
|
||||
provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
|
||||
val batch = BatchOperation(provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newDelete(Events.CONTENT_URI.asSyncAdapter(account))
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
|
||||
}
|
||||
deleted = batch.commit()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
logger.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.fine("Found deleted exception, removing and re-scheduling original event (if available)")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account),
|
||||
arrayOf(LocalEvent.COLUMN_SEQUENCE),
|
||||
null, null, null)?.use { cursor2 ->
|
||||
if (cursor2.moveToNext()) {
|
||||
// original event is available
|
||||
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
|
||||
|
||||
// re-schedule original event and set it to DIRTY
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1))
|
||||
}
|
||||
}
|
||||
|
||||
// completely remove deleted exception
|
||||
batch.enqueue(BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account)))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
logger.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.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
// original event to DIRTY
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
|
||||
.withValue(Events.DIRTY, 1))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
|
||||
*
|
||||
* @return number of affected events
|
||||
*/
|
||||
fun deleteDirtyEventsWithoutInstances() {
|
||||
provider.query(
|
||||
Events.CONTENT_URI.asSyncAdapter(account),
|
||||
arrayOf(Events._ID),
|
||||
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events)
|
||||
null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val eventID = cursor.getLong(0)
|
||||
|
||||
// get number of instances
|
||||
val numEventInstances = LocalEvent.numInstances(provider, account, eventID)
|
||||
|
||||
// delete event if there are no instances
|
||||
if (numEventInstances == 0) {
|
||||
logger.info("Marking event #$eventID without instances as deleted")
|
||||
LocalEvent.markAsDeleted(provider, account, eventID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidCalendarFactory<LocalCalendar> {
|
||||
|
||||
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
|
||||
LocalCalendar(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalCalendarStore @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
db: AppDatabase,
|
||||
val logger: Logger
|
||||
): LocalDataStore<LocalCalendar> {
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (fromCollection.color != null)
|
||||
fromCollection.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(fromCollection, 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)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & syncable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
|
||||
logger.log(Level.INFO, "Adding local calendar", values)
|
||||
val uri = AndroidCalendar.create(account, provider, values)
|
||||
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${Calendars.SYNC_EVENTS}!=0", null)
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())
|
||||
|
||||
logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values)
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && 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)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timezoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
|
||||
}
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalCalendar) {
|
||||
logger.log(Level.INFO, "Deleting local calendar", localCollection)
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
|
||||
/**
|
||||
* Represents a local data store for a specific collection type.
|
||||
* Manages creation, update, and deletion of collections of the given type.
|
||||
*/
|
||||
interface LocalDataStore<T: LocalCollection<*>> {
|
||||
|
||||
/**
|
||||
* Creates a new local collection from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param fromCollection collection info
|
||||
*
|
||||
* @return the new local collection, or `null` if creation failed
|
||||
*/
|
||||
fun create(provider: ContentProviderClient, fromCollection: Collection): T?
|
||||
|
||||
/**
|
||||
* Returns all local collections of the data store.
|
||||
*
|
||||
* @param account the account that the data store is associated with
|
||||
* @param provider the content provider client
|
||||
*
|
||||
* @return a list of all local collections
|
||||
*/
|
||||
fun getAll(account: Account, provider: ContentProviderClient): List<T>
|
||||
|
||||
/**
|
||||
* Updates the local collection with the data from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param localCollection the local collection to update
|
||||
* @param fromCollection collection info
|
||||
*/
|
||||
fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection)
|
||||
|
||||
/**
|
||||
* Deletes the local collection.
|
||||
*
|
||||
* @param localCollection the local collection to delete
|
||||
*/
|
||||
fun delete(localCollection: T)
|
||||
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.AndroidEventFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.ICalendar
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import java.util.UUID
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
|
||||
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
|
||||
|
||||
/**
|
||||
* Marks the event as deleted
|
||||
* @param eventID
|
||||
*/
|
||||
fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) {
|
||||
provider.update(
|
||||
ContentUris.withAppendedId(
|
||||
Events.CONTENT_URI,
|
||||
eventID
|
||||
).asSyncAdapter(account),
|
||||
ContentValues(1).apply {
|
||||
put(Events.DELETED, 1)
|
||||
},
|
||||
null,null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds the amount of direct instances this event has (without exceptions); used by [numInstances]
|
||||
* to find the number of instances of exceptions.
|
||||
*
|
||||
* The number of returned instances may vary with the Android version.
|
||||
*
|
||||
* @return number of direct event instances (not counting instances of exceptions); *null* if
|
||||
* the number can't be determined or if the event has no last date (recurring event without last instance)
|
||||
*/
|
||||
fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
|
||||
// query event to get first and last instance
|
||||
var first: Long? = null
|
||||
var last: Long? = null
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(
|
||||
Events.CONTENT_URI,
|
||||
eventID
|
||||
),
|
||||
arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null
|
||||
)?.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
if (!cursor.isNull(0))
|
||||
first = cursor.getLong(0)
|
||||
if (!cursor.isNull(1))
|
||||
last = cursor.getLong(1)
|
||||
}
|
||||
// if this event doesn't have a last occurence, it's endless and always has instances
|
||||
if (first == null || last == null)
|
||||
return null
|
||||
|
||||
/* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances
|
||||
on the fly and it doesn't accept those values. So we use the first/last actual occurence
|
||||
of the event (calculated by Android). */
|
||||
val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account)
|
||||
.buildUpon()
|
||||
.appendPath(first.toString()) // begin timestamp
|
||||
.appendPath(last.toString()) // end timestamp
|
||||
.build()
|
||||
|
||||
var numInstances = 0
|
||||
provider.query(
|
||||
instancesUri, null,
|
||||
"${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
numInstances += cursor.count
|
||||
}
|
||||
return numInstances
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the total number of instances this event has (including instances of exceptions)
|
||||
*
|
||||
* The number of returned instances may vary with the Android version.
|
||||
*
|
||||
* @return number of direct event instances (not counting instances of exceptions); *null* if
|
||||
* the number can't be determined or if the event has no last date (recurring event without last instance)
|
||||
*/
|
||||
fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
|
||||
// num instances of the main event
|
||||
var numInstances = numDirectInstances(provider, account, eventID) ?: return null
|
||||
|
||||
// add the number of instances of every main event's exception
|
||||
provider.query(
|
||||
Events.CONTENT_URI,
|
||||
arrayOf(Events._ID),
|
||||
"${Events.ORIGINAL_ID}=?", // get exception events of the main event
|
||||
arrayOf("$eventID"), null
|
||||
)?.use { exceptionsEventCursor ->
|
||||
while (exceptionsEventCursor.moveToNext()) {
|
||||
val exceptionEventID = exceptionsEventCursor.getLong(0)
|
||||
val exceptionInstances = numDirectInstances(provider, account, exceptionEventID)
|
||||
|
||||
if (exceptionInstances == null)
|
||||
// number of instances of exception can't be determined; so the total number of instances is also unclear
|
||||
return null
|
||||
|
||||
numInstances += exceptionInstances
|
||||
}
|
||||
}
|
||||
return numInstances
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
private set
|
||||
|
||||
override var eTag: String? = null
|
||||
override var scheduleTag: String? = null
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
var weAreOrganizer = false
|
||||
private set
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
|
||||
val event = requireNotNull(event)
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
|
||||
super.populateEvent(row, groupScheduled)
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
|
||||
val event = requireNotNull(event)
|
||||
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
.withValue(Events.DELETED, 0)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
|
||||
super.buildEvent(recurrence, builder)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates and sets a new UID in the calendar provider, if no UID is already set.
|
||||
* It also returns the desired file name for the event for further processing in the sync algorithm.
|
||||
*
|
||||
* @return file name to use at upload
|
||||
*/
|
||||
override fun prepareForUpload(): String {
|
||||
// make sure that UID is set
|
||||
val uid: String = event!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in calendar provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Events.UID_2445, newUid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
event?.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
val uidIsGoodFilename = uid.all { char ->
|
||||
// see RFC 2396 2.2
|
||||
char.isLetterOrDigit() || arrayOf( // allow letters and digits
|
||||
';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?'
|
||||
'-','_','.','!','~','*','\'','(',')' // allow unreserved characters
|
||||
).contains(char)
|
||||
}
|
||||
return if (uidIsGoodFilename)
|
||||
"$uid.ics" // use UID as file name
|
||||
else
|
||||
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
|
||||
}
|
||||
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
val values = ContentValues(5)
|
||||
if (fileName != null)
|
||||
values.put(Events._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
values.put(Events.DIRTY, 0)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1).apply { put(Events.DELETED, 0) }
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
|
||||
LocalEvent(calendar, values)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
interface LocalResource<in TData: Any> {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Resource is present on remote server. This flag is used to identify resources
|
||||
* which are not present on the remote server anymore and can be deleted at the end
|
||||
* of the synchronization.
|
||||
*/
|
||||
const val FLAG_REMOTELY_PRESENT = 1
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unique ID which identifies the resource in the local storage. May be null if the
|
||||
* resource has not been saved yet.
|
||||
*/
|
||||
val id: Long?
|
||||
|
||||
/**
|
||||
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
|
||||
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
|
||||
* (in this case, [fileName] is the remote file name).
|
||||
*/
|
||||
val fileName: String?
|
||||
|
||||
/** remote ETag for the resource */
|
||||
var eTag: String?
|
||||
|
||||
/** remote Schedule-Tag for the resource */
|
||||
var scheduleTag: String?
|
||||
|
||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Prepares the resource for uploading:
|
||||
*
|
||||
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
|
||||
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
|
||||
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||
* was actually used.
|
||||
*
|
||||
* @return new file name of the resource (like "<uid>.vcf")
|
||||
*/
|
||||
fun prepareForUpload(): String
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
*
|
||||
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Sets (local) flags of the resource. At the moment, the only allowed values are
|
||||
* 0 and [FLAG_REMOTELY_PRESENT].
|
||||
*/
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
|
||||
/**
|
||||
* Adds the data object to the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the created row (e.g. event URI)
|
||||
*/
|
||||
fun add(): Uri
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData): Uri
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
*
|
||||
* @return number of affected rows
|
||||
*/
|
||||
fun delete(): Int
|
||||
|
||||
/**
|
||||
* Undoes deletion of the data object from the content provider.
|
||||
*/
|
||||
fun resetDeleted()
|
||||
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskFactory
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.UUID
|
||||
|
||||
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val uid: String = task!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in tasks provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks._UID, newUid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
task!!.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(Tasks._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.*
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* App-specific implementation of a task list.
|
||||
*
|
||||
* [TaskLists._SYNC_ID] is used to store the task list URL.
|
||||
*/
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(context: Context, oldName: String, newName: String) {
|
||||
TaskProvider.acquire(context)?.use { provider ->
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
provider.client.update(
|
||||
Tasks.getContentUri(provider.name.authority),
|
||||
values,
|
||||
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val logger = Logger.getGlobal()
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
override val readOnly
|
||||
get() =
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
|
||||
override val collectionUrl: String?
|
||||
get() = syncId
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
override fun populate(values: ContentValues) {
|
||||
super.populate(values)
|
||||
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||
}
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else // task was modified, increase sequence
|
||||
task.sequence = sequence + 1
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalTask.COLUMN_FLAGS, flags)
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
) = LocalTaskList(account, provider, providerName, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTaskListStore @AssistedInject constructor(
|
||||
@Assisted authority: String,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
val db: AppDatabase,
|
||||
val logger: Logger
|
||||
): LocalDataStore<LocalTaskList> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(authority: String): LocalTaskListStore
|
||||
}
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
private val providerName = TaskProvider.ProviderName.fromAuthority(authority)
|
||||
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||
val uri = create(account, provider, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(info, withColor = true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
values.put(TaskLists.VISIBLE, 1)
|
||||
return DmfsTaskList.Companion.create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(TaskLists.LIST_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly)
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
|
||||
else
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
}
|
||||
|
||||
|
||||
override fun delete(localCollection: LocalTaskList) {
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Logic for refreshing the list of collections and home-sets and related information.
|
||||
*/
|
||||
class CollectionListRefresher @AssistedInject constructor(
|
||||
@Assisted val service: Service,
|
||||
@Assisted val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val logger: Logger,
|
||||
private val settings: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
|
||||
}
|
||||
|
||||
|
||||
private val alreadyQueried = mutableSetOf<HttpUrl>()
|
||||
|
||||
/**
|
||||
* Starting at current-user-principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
*
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
*/
|
||||
internal fun discoverHomesets(principalUrl: HttpUrl, level: Int = 0) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Define homeset class and properties to look for
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val properties: Array<Property.Name>
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME, ResourceType.NAME)
|
||||
}
|
||||
Service.TYPE_CALDAV -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
properties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
}
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *properties) { davResponse, _ ->
|
||||
alreadyQueried += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
// Homeset is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals and still be considered "personal" (belonging to the current-user-principal).
|
||||
homeSetRepository.insertOrUpdateByUrl(
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java
|
||||
)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueried.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(resource, level + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes home-sets and their collections.
|
||||
*
|
||||
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
|
||||
* or marked as homeless - in case a collection was removed from its home-set.
|
||||
*
|
||||
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [refreshHomelessCollections].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = homeSetRepository.getByService(service.id).associateBy { it.url }.toMutableMap()
|
||||
for((homeSetUrl, localHomeset) in homesets) {
|
||||
logger.fine("Listing home set $homeSetUrl")
|
||||
|
||||
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
|
||||
// is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless.
|
||||
val localHomesetCollections = db.collectionDao()
|
||||
.getByServiceAndHomeset(service.id, localHomeset.id)
|
||||
.associateBy { it.url }
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, relation ->
|
||||
// Note: This callback may be called multiple times ([MultiResponseCallback])
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
// this response is about the homeset itself
|
||||
localHomeset.displayName = response[DisplayName::class.java]?.displayName
|
||||
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
|
||||
homeSetRepository.insertOrUpdateByUrl(localHomeset)
|
||||
}
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
val collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
|
||||
collection.serviceId = service.id
|
||||
collection.homeSetId = localHomeset.id
|
||||
collection.sync = shouldPreselect(collection, homesets.values)
|
||||
|
||||
// .. and save the principal url (collection owner)
|
||||
response[Owner::class.java]?.href
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl ->
|
||||
val principal = Principal.fromServiceAndUrl(service, principalUrl)
|
||||
val id = db.principalDao().insertOrUpdate(service.id, principal)
|
||||
collection.ownerId = id
|
||||
}
|
||||
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (isUsableCollection(collection))
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection)
|
||||
|
||||
// Remove this collection from queue - because it was found in the home set
|
||||
localHomesetCollections.remove(collection.url)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete home set locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
homeSetRepository.delete(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
|
||||
for ((_, homelessCollection) in localHomesetCollections) {
|
||||
homelessCollection.homeSetId = null
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(homelessCollection)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes collections which don't have a homeset.
|
||||
*
|
||||
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
|
||||
*/
|
||||
internal fun refreshHomelessCollections() {
|
||||
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for((url, localCollection) in homelessCollections) try {
|
||||
DavResource(httpClient, url).propfind(0, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!isUsableCollection(collection))
|
||||
return@let
|
||||
collection.serviceId = localCollection.serviceId // use same service ID as previous entry
|
||||
|
||||
// .. and save the principal url (collection owner)
|
||||
response[Owner::class.java]?.href
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl ->
|
||||
val principal = Principal.fromServiceAndUrl(service, principalUrl)
|
||||
val principalId = db.principalDao().insertOrUpdate(service.id, principal)
|
||||
collection.ownerId = principalId
|
||||
}
|
||||
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection)
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
collectionRepository.delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the principals (get their current display names).
|
||||
* Also removes principals which do not own any collections anymore.
|
||||
*/
|
||||
internal fun refreshPrincipals() {
|
||||
// Refresh principals (collection owner urls)
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
for (oldPrincipal in principals) {
|
||||
val principalUrl = oldPrincipal.url
|
||||
logger.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *RefreshCollectionsWorker.DAV_PRINCIPAL_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
logger.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach {principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether given collection is usable, by checking that either
|
||||
* - CalDAV/CardDAV: service and collection type match, or
|
||||
* - WebCal: subscription source URL is not empty
|
||||
*/
|
||||
private fun isUsableCollection(collection: Collection) =
|
||||
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
|
||||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
|
||||
|
||||
/**
|
||||
* Whether to preselect the given collection for synchronisation, according to the
|
||||
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
|
||||
*
|
||||
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
|
||||
*
|
||||
* Before a collection is pre-selected, we check whether its URL matches the regexp in
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
|
||||
*
|
||||
* @param collection the collection to check
|
||||
* @param homesets list of home-sets (to check whether collection is in a personal home-set)
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homesets: Iterable<HomeSet>): Boolean {
|
||||
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
|
||||
|
||||
val excluded by lazy {
|
||||
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
|
||||
if (!excludedRegex.isNullOrEmpty())
|
||||
Regex(excludedRegex).containsMatchIn(collection.url.toString())
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
return when (shouldPreselect) {
|
||||
Settings.PRESELECT_COLLECTIONS_ALL ->
|
||||
// preselect if collection url is not excluded
|
||||
!excluded
|
||||
|
||||
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
|
||||
// preselect if is personal (in a personal home-set), but not excluded
|
||||
homesets
|
||||
.filter { homeset -> homeset.personal }
|
||||
.map { homeset -> homeset.id }
|
||||
.contains(collection.homeSetId)
|
||||
&& !excluded
|
||||
|
||||
else -> // don't preselect
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.openid.appauth.AuthState
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* Must not be called from main thread as it uses blocking I/O
|
||||
* and may run migrations.
|
||||
*
|
||||
* @param account account to take settings from
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account is not a DAVx5 account
|
||||
*/
|
||||
@WorkerThread
|
||||
class AccountSettings @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val migrationsFactory: AccountSettingsMigrations.Factory,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
/**
|
||||
* Must not be called from main thread as AccountSettings uses blocking I/O and may run
|
||||
* migrations.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun create(account: Account): AccountSettings
|
||||
}
|
||||
|
||||
init {
|
||||
if (Looper.getMainLooper() == Looper.myLooper())
|
||||
throw IllegalThreadStateException("AccountSettings may not be used on main thread")
|
||||
}
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
init {
|
||||
val allowedAccountTypes = arrayOf(
|
||||
context.getString(R.string.account_type),
|
||||
"at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest
|
||||
)
|
||||
if (!allowedAccountTypes.contains(account.type))
|
||||
throw IllegalArgumentException("Invalid account type: ${account.type}")
|
||||
|
||||
// synchronize because account migration must only be run one time
|
||||
synchronized(AccountSettings::class.java) {
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
|
||||
}
|
||||
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
if (currentlyUpdating) {
|
||||
logger.severe("Redundant call: migration created AccountSettings(). This must never happen.")
|
||||
throw IllegalStateException("Redundant call: migration created AccountSettings()")
|
||||
} else {
|
||||
currentlyUpdating = true
|
||||
update(version)
|
||||
currentlyUpdating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
fun credentials() = Credentials(
|
||||
accountManager.getUserData(account, KEY_USERNAME),
|
||||
accountManager.getPassword(account),
|
||||
|
||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
|
||||
|
||||
accountManager.getUserData(account, KEY_AUTH_STATE)?.let { json ->
|
||||
AuthState.jsonDeserialize(json)
|
||||
}
|
||||
)
|
||||
|
||||
fun credentials(credentials: Credentials) {
|
||||
// Basic/Digest auth
|
||||
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
|
||||
accountManager.setPassword(account, credentials.password)
|
||||
|
||||
// client certificate
|
||||
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
|
||||
// OAuth
|
||||
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString())
|
||||
}
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
/**
|
||||
* Gets the currently set sync interval for this account in seconds.
|
||||
*
|
||||
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set
|
||||
*/
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
val addrBookAuthority = context.getString(R.string.address_books_authority)
|
||||
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0 && authority != addrBookAuthority)
|
||||
return null
|
||||
|
||||
val key = when {
|
||||
authority == addrBookAuthority ->
|
||||
KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
authority == CalendarContract.AUTHORITY ->
|
||||
KEY_SYNC_INTERVAL_CALENDARS
|
||||
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
|
||||
KEY_SYNC_INTERVAL_TASKS
|
||||
else -> return null
|
||||
}
|
||||
return accountManager.getUserData(account, key)?.toLong()
|
||||
}
|
||||
|
||||
fun getTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
|
||||
|
||||
/**
|
||||
* Sets the sync interval and en- or disables periodic sync for the given account and authority.
|
||||
* Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* This method blocks until a worker as been created and enqueued (sync active) or removed
|
||||
* (sync disabled), so it should not be called from the UI thread.
|
||||
*
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param _seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
|
||||
* otherwise (must be ≥ 15 min): automatic sync will be enabled and set to the given number of seconds
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncInterval(authority: String, _seconds: Long) {
|
||||
val seconds =
|
||||
if (_seconds != SYNC_INTERVAL_MANUALLY && _seconds < 60*15)
|
||||
60*15
|
||||
else
|
||||
_seconds
|
||||
|
||||
// Store (user defined) sync interval in account settings
|
||||
val key = when {
|
||||
authority == context.getString(R.string.address_books_authority) ->
|
||||
KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
authority == CalendarContract.AUTHORITY ->
|
||||
KEY_SYNC_INTERVAL_CALENDARS
|
||||
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
|
||||
KEY_SYNC_INTERVAL_TASKS
|
||||
else -> {
|
||||
logger.warning("Sync interval not applicable to authority $authority")
|
||||
return
|
||||
}
|
||||
}
|
||||
accountManager.setAndVerifyUserData(account, key, seconds.toString())
|
||||
|
||||
// update sync workers (needs already updated sync interval in AccountSettings)
|
||||
updatePeriodicSyncWorker(authority, seconds, getSyncWifiOnly())
|
||||
|
||||
// Also enable/disable content change triggered syncs (SyncFramework automatic sync).
|
||||
// We could make this a separate user adjustable setting later on.
|
||||
setSyncOnContentChange(authority, seconds != SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
|
||||
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* We use the sync adapter framework only for the trigger, actual syncing is implemented
|
||||
* with WorkManager. The trigger comes in through SyncAdapterService.
|
||||
*
|
||||
* This method blocks until the sync-on-content-change has been enabled or disabled, so it
|
||||
* should not be called from the UI thread.
|
||||
*
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean {
|
||||
// Enable content change triggers (sync adapter framework)
|
||||
val setContentTrigger: () -> Boolean =
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
if (enable) {{
|
||||
logger.fine("Enabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers
|
||||
// Remove unwanted sync framework periodic syncs created by setSyncAutomatically
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
/* return */ ContentResolver.getSyncAutomatically(account, authority)
|
||||
}} else {{
|
||||
logger.fine("Disabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers
|
||||
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
|
||||
}}
|
||||
|
||||
// try up to 10 times with 100 ms pause
|
||||
for (idxTry in 0 until 10) {
|
||||
if (setContentTrigger())
|
||||
// successfully set
|
||||
return true
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() =
|
||||
if (settingsManager.containsKey(KEY_WIFI_ONLY))
|
||||
settingsManager.getBoolean(KEY_WIFI_ONLY)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
// update sync workers (needs already updated wifi-only flag in AccountSettings)
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly)
|
||||
}
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? =
|
||||
if (getSyncWifiOnly()) {
|
||||
val strSsids = if (settingsManager.containsKey(KEY_WIFI_ONLY_SSIDS))
|
||||
settingsManager.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS)
|
||||
strSsids?.split(',')
|
||||
} else
|
||||
null
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY_SSIDS, ssids?.joinToString(",").trimToNull())
|
||||
|
||||
fun getIgnoreVpns(): Boolean =
|
||||
when (accountManager.getUserData(account, KEY_IGNORE_VPNS)) {
|
||||
null -> settingsManager.getBoolean(KEY_IGNORE_VPNS)
|
||||
"0" -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun setIgnoreVpns(ignoreVpns: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0")
|
||||
|
||||
/**
|
||||
* Updates the periodic sync worker of an authority according to
|
||||
*
|
||||
* - the sync interval and
|
||||
* - the _Sync WiFi only_ flag.
|
||||
*
|
||||
* @param authority periodic sync workers for this authority will be updated
|
||||
* @param seconds sync interval in seconds (`null` or [SYNC_INTERVAL_MANUALLY] disables periodic sync)
|
||||
* @param wiFiOnly sync Wifi only flag
|
||||
*/
|
||||
fun updatePeriodicSyncWorker(authority: String, seconds: Long?, wiFiOnly: Boolean) {
|
||||
try {
|
||||
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
logger.fine("Disabling periodic sync of $account/$authority")
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
} else {
|
||||
logger.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
|
||||
syncWorkerManager.enablePeriodic(account, authority, seconds, wiFiOnly)
|
||||
}.result.get() // On operation (enable/disable) failure exception is thrown
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
return if (strDays != null) {
|
||||
val days = strDays.toInt()
|
||||
if (days < 0)
|
||||
null
|
||||
else
|
||||
days
|
||||
} else
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
/**
|
||||
* Takes the default alarm setting (in this order) from
|
||||
*
|
||||
* 1. the local account settings
|
||||
* 2. the settings provider (unless the value is -1 there).
|
||||
*
|
||||
* @return A default reminder shall be created this number of minutes before the start of every
|
||||
* non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun getDefaultAlarm() =
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
|
||||
|
||||
/**
|
||||
* Sets the default alarm value in the local account settings, if the new value differs
|
||||
* from the value of the settings provider. If the new value is the same as the value of
|
||||
* the settings provider, the local setting will be deleted, so that the settings provider
|
||||
* value applies.
|
||||
*
|
||||
* @param minBefore The number of minutes a default reminder shall be created before the
|
||||
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun setDefaultAlarm(minBefore: Int?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
|
||||
if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
|
||||
fun getManageCalendarColors() =
|
||||
if (settingsManager.containsKey(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settingsManager.getBoolean(KEY_MANAGE_CALENDAR_COLORS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
fun getEventColors() =
|
||||
if (settingsManager.containsKey(KEY_EVENT_COLORS))
|
||||
settingsManager.getBoolean(KEY_EVENT_COLORS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
|
||||
fun setEventColors(useColors: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
fun getGroupMethod(): GroupMethod {
|
||||
val name = settingsManager.getString(KEY_CONTACT_GROUP_METHOD) ?:
|
||||
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
|
||||
if (name != null)
|
||||
try {
|
||||
return GroupMethod.valueOf(name)
|
||||
}
|
||||
catch (_: IllegalArgumentException) {
|
||||
}
|
||||
return GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
fun setGroupMethod(method: GroupMethod) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
|
||||
}
|
||||
|
||||
|
||||
// UI settings
|
||||
|
||||
data class ShowOnlyPersonal(
|
||||
val onlyPersonal: Boolean,
|
||||
val locked: Boolean
|
||||
)
|
||||
|
||||
fun getShowOnlyPersonal(): ShowOnlyPersonal {
|
||||
@Suppress("DEPRECATION")
|
||||
val pair = getShowOnlyPersonalPair()
|
||||
return ShowOnlyPersonal(onlyPersonal = pair.first, locked = !pair.second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether only personal collections should be shown.
|
||||
*
|
||||
* @return [Pair] of values:
|
||||
*
|
||||
* 1. (first) whether only personal collections should be shown
|
||||
* 2. (second) whether the user shall be able to change the setting (= setting not locked)
|
||||
*/
|
||||
@Deprecated("Use getShowOnlyPersonal() instead", replaceWith = ReplaceWith("getShowOnlyPersonal()"))
|
||||
fun getShowOnlyPersonalPair(): Pair<Boolean, Boolean> =
|
||||
when (settingsManager.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
|
||||
0 -> Pair(false, false)
|
||||
1 -> Pair(true, false)
|
||||
else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true)
|
||||
}
|
||||
|
||||
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null)
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
logger.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
val migrations = migrationsFactory.create(
|
||||
account = account,
|
||||
accountSettings = this
|
||||
)
|
||||
val updateProc = AccountSettingsMigrations::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(migrations)
|
||||
|
||||
logger.info("Account version update successful")
|
||||
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 17
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
|
||||
|
||||
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
|
||||
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
/** OAuth [AuthState] (serialized as JSON) */
|
||||
const val KEY_AUTH_STATE = "auth_state"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
|
||||
|
||||
/** Time range limitation to the past [in days]. Values:
|
||||
*
|
||||
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
* - <0 (typically -1): no limit
|
||||
* - n>0: entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/**
|
||||
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
|
||||
* Value can be null (no default alarm) or an integer (default alarm shall be created this
|
||||
* number of minutes before the event/task).
|
||||
*/
|
||||
const val KEY_DEFAULT_ALARM = "default_alarm"
|
||||
|
||||
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
|
||||
value = *null* (not existing): true (default);
|
||||
"0" false */
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/** Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = *null* (not existing) false (default);
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
*null (not existing)* groups as separate vCards (default);
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
/** UI preference: Show only personal collections
|
||||
value = *null* (not existing) show all collections (default);
|
||||
"1" show only personal collections */
|
||||
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
/** Static property to indicate whether AccountSettings migration is currently running.
|
||||
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
|
||||
@Volatile
|
||||
var currentlyUpdating = false
|
||||
|
||||
fun initialUserData(credentials: Credentials?): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
if (credentials != null) {
|
||||
if (credentials.username != null)
|
||||
bundle.putString(KEY_USERNAME, credentials.username)
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
|
||||
if (credentials.authState != null)
|
||||
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Lazy
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.property.Url
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
class AccountSettingsMigrations @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
@Assisted val accountSettings: AccountSettings,
|
||||
@ApplicationContext val context: Context,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val db: AppDatabase,
|
||||
private val localAddressBookStore: LocalAddressBookStore,
|
||||
private val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, accountSettings: AccountSettings): AccountSettingsMigrations
|
||||
}
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
|
||||
/* IMPORTANT: No more migrations must be added without tests. Maybe https://github.com/bitfireAT/davx5-ose/issues/935
|
||||
is done before adding new migrations. Then that PR should establish a way to define migration tests. Otherwise,
|
||||
the next migration must add a test and therefore defines the method how run migration tests. */
|
||||
|
||||
/**
|
||||
* With DAVx5 4.4.3 address book account names now contain the collection ID as a unique
|
||||
* identifier. We need to update the address book account names.
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_16_17() {
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
} catch (e: SecurityException) {
|
||||
// Not setting the collection ID will cause the address books to removed and fully re-synced as soon as there are permissions.
|
||||
logger.log(Level.WARNING, "Missing permissions for contacts authority, won't set collection ID for address books", e)
|
||||
null
|
||||
}?.use { provider ->
|
||||
val service = serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV) ?: return
|
||||
|
||||
// Get all old address books of this account, i.e. the ones which have a "real_account_name" of this account.
|
||||
// After this migration is run, address books won't be associated to accounts anymore but only to their respective collection/URL.
|
||||
val oldAddressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
.filter { addressBookAccount ->
|
||||
account.name == accountManager.getUserData(addressBookAccount, "real_account_name")
|
||||
}
|
||||
|
||||
for (oldAddressBookAccount in oldAddressBookAccounts) {
|
||||
// Old address books only have a URL, so use it to determine the collection ID
|
||||
logger.info("Migrating address book ${oldAddressBookAccount.name}")
|
||||
val oldAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider)
|
||||
val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL)
|
||||
collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection ->
|
||||
// Set collection ID and rename the account
|
||||
localAddressBookStore.update(provider, oldAddressBook, collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Between DAVx5 4.4.1-beta.1 and 4.4.1-rc.1 (both v15), the periodic sync workers were renamed (moved to another
|
||||
* package) and thus automatic synchronization stopped (because the enqueued workers rely on the full class
|
||||
* name and no new workers were enqueued). Here we enqueue all periodic sync workers again with the correct class name.
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_15_16() {
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
logger.info("Re-enqueuing periodic sync workers for $account/$authority, if necessary")
|
||||
|
||||
/* A maybe existing periodic worker references the old class name (even if it failed and/or is not active). So
|
||||
we need to explicitly disable and prune all workers. Just updating the worker is not enough – WorkManager will update
|
||||
the work details, but not the class name. */
|
||||
val disableOp = syncWorkerManager.disablePeriodic(account, authority)
|
||||
disableOp.result.get() // block until worker with old name is disabled
|
||||
|
||||
val pruneOp = WorkManager.getInstance(context).pruneWork()
|
||||
pruneOp.result.get() // block until worker with old name is removed from DB
|
||||
|
||||
val interval = accountSettings.getSyncInterval(authority)
|
||||
if (interval != null && interval != AccountSettings.SYNC_INTERVAL_MANUALLY) {
|
||||
// There's a sync interval for this account/authority; a periodic sync worker should be there, too.
|
||||
val onlyWifi = accountSettings.getSyncWifiOnly()
|
||||
syncWorkerManager.enablePeriodic(account, authority, interval, onlyWifi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the periodic sync workers by re-setting the same sync interval.
|
||||
*
|
||||
* The goal is to add the [BaseSyncWorker.commonTag] to all existing periodic sync workers so that they can be detected by
|
||||
* the new [BaseSyncWorker.exists] and [at.bitfire.davdroid.ui.AccountsActivity.Model].
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_14_15() {
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
val interval = accountSettings.getSyncInterval(authority)
|
||||
accountSettings.setSyncInterval(authority, interval ?: AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables all sync adapter periodic syncs for every authority. Then enables
|
||||
* corresponding PeriodicSyncWorkers
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_13_14() {
|
||||
// Cancel any potentially running syncs for this account (sync framework)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
val authorities = listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
// Enable PeriodicSyncWorker (WorkManager), with known intervals
|
||||
v14_enableWorkManager(authority)
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
v14_disableSyncFramework(authority)
|
||||
}
|
||||
}
|
||||
private fun v14_enableWorkManager(authority: String) {
|
||||
val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval ->
|
||||
accountSettings.setSyncInterval(authority, syncInterval)
|
||||
} ?: false
|
||||
logger.info("PeriodicSyncWorker for $account/$authority enabled=$enabled")
|
||||
}
|
||||
private fun v14_disableSyncFramework(authority: String) {
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
val disable: () -> Boolean = {
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
for (sync in ContentResolver.getPeriodicSyncs(account, authority))
|
||||
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
|
||||
|
||||
// check whether syncs are really disabled
|
||||
var result = true
|
||||
for (sync in ContentResolver.getPeriodicSyncs(account, authority)) {
|
||||
logger.info("Sync framework still has a periodic sync for $account/$authority: $sync")
|
||||
result = false
|
||||
}
|
||||
result
|
||||
}
|
||||
// try up to 10 times with 100 ms pause
|
||||
var success = false
|
||||
for (idxTry in 0 until 10) {
|
||||
success = disable()
|
||||
if (success)
|
||||
break
|
||||
Thread.sleep(200)
|
||||
}
|
||||
logger.info("Sync framework periodic syncs for $account/$authority disabled=$success")
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Not a per-account migration, but not a database migration, too, so it fits best there.
|
||||
* Best future solution would be that SettingsManager manages versions and migrations.
|
||||
*
|
||||
* Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port.
|
||||
*/
|
||||
private fun update_12_13() {
|
||||
// proxy settings are managed by SharedPreferencesProvider
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
// old setting names
|
||||
val overrideProxy = "override_proxy"
|
||||
val overrideProxyHost = "override_proxy_host"
|
||||
val overrideProxyPort = "override_proxy_port"
|
||||
|
||||
val edit = preferences.edit()
|
||||
if (preferences.contains(overrideProxy)) {
|
||||
if (preferences.getBoolean(overrideProxy, false))
|
||||
// override_proxy set, migrate to proxy_type = HTTP
|
||||
edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP)
|
||||
edit.remove(overrideProxy)
|
||||
}
|
||||
if (preferences.contains(overrideProxyHost)) {
|
||||
preferences.getString(overrideProxyHost, null)?.let { host ->
|
||||
edit.putString(Settings.PROXY_HOST, host)
|
||||
}
|
||||
edit.remove(overrideProxyHost)
|
||||
}
|
||||
if (preferences.contains(overrideProxyPort)) {
|
||||
val port = preferences.getInt(overrideProxyPort, 0)
|
||||
if (port != 0)
|
||||
edit.putInt(Settings.PROXY_PORT, port)
|
||||
edit.remove(overrideProxyPort)
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
|
||||
* convert legacy unknown properties to the current format.
|
||||
*/
|
||||
private fun update_11_12() {
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
|
||||
// to the given account! So all extended properties will be processed number-of-accounts times.
|
||||
val extUri = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account)
|
||||
|
||||
provider.query(
|
||||
extUri, arrayOf(
|
||||
CalendarContract.ExtendedProperties._ID, // idx 0
|
||||
CalendarContract.ExtendedProperties.NAME, // idx 1
|
||||
CalendarContract.ExtendedProperties.VALUE // idx 2
|
||||
), null, null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val rawValue = cursor.getString(2)
|
||||
|
||||
val uri by lazy {
|
||||
ContentUris.withAppendedId(CalendarContract.ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account)
|
||||
}
|
||||
|
||||
when (cursor.getString(1)) {
|
||||
UnknownProperty.CONTENT_ITEM_TYPE -> {
|
||||
// unknown property; check whether it's a URL
|
||||
try {
|
||||
val property = UnknownProperty.fromJsonString(rawValue)
|
||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, AndroidEvent.EXTNAME_URL)
|
||||
newValues.put(CalendarContract.ExtendedProperties.VALUE, property.value)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(
|
||||
Level.WARNING,
|
||||
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"unknown-property" -> {
|
||||
// unknown property (deprecated format); convert to current format
|
||||
try {
|
||||
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
|
||||
ObjectInputStream(stream).use {
|
||||
(it.readObject() as? Property)?.let { property ->
|
||||
// rewrite to current format
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
newValues.put(CalendarContract.ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
|
||||
}
|
||||
}
|
||||
|
||||
"unknown-property.v2" -> {
|
||||
// unknown property (deprecated MIME type); rewrite to current MIME type
|
||||
val newValues = ContentValues(1)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
|
||||
* again when the tasks provider is switched.
|
||||
*/
|
||||
private fun update_10_11() {
|
||||
tasksAppManager.get().currentProvider()?.let { provider ->
|
||||
val interval = accountSettings.getSyncInterval(provider.authority)
|
||||
if (interval != null)
|
||||
accountManager.setAndVerifyUserData(account,
|
||||
AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Task synchronization now handles alarms, categories, relations and unknown properties.
|
||||
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
|
||||
*
|
||||
* Also update the allowed reminder types for calendars.
|
||||
**/
|
||||
private fun update_9_10() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
||||
val emptyETag = ContentValues(1)
|
||||
emptyETag.putNull(LocalTask.COLUMN_ETAG)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
provider.update(
|
||||
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
AndroidCalendar.calendarBaseValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
|
||||
* Disable it on those accounts for the future.
|
||||
*/
|
||||
private fun update_8_9() {
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) {
|
||||
logger.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
@SuppressLint("Recycle")
|
||||
/**
|
||||
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
|
||||
* SEQUENCE and should not be used for the eTag.
|
||||
*/
|
||||
private fun update_7_8() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
// ETag is now in sync_version instead of sync1
|
||||
// UID is now in _uid instead of sync2
|
||||
provider.client.query(provider.tasksUri().asSyncAdapter(account),
|
||||
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
|
||||
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.type, account.name), null)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val eTag = cursor.getString(1)
|
||||
val uid = cursor.getString(2)
|
||||
val values = ContentValues(4)
|
||||
values.put(TaskContract.Tasks._UID, uid)
|
||||
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
|
||||
values.putNull(TaskContract.Tasks.SYNC1)
|
||||
values.putNull(TaskContract.Tasks.SYNC2)
|
||||
logger.log(Level.FINER, "Updating task $id", values)
|
||||
provider.client.update(
|
||||
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
|
||||
values, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
}
|
||||
|
||||
// update allowed WiFi settings key
|
||||
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
|
||||
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID)
|
||||
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 5 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import net.fortuna.ical4j.model.Component
|
||||
import net.fortuna.ical4j.model.component.VAlarm
|
||||
import net.fortuna.ical4j.model.property.Action
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.time.Duration
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events (VEVENT).
|
||||
*/
|
||||
class CalendarSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted accountSettings: AccountSettings,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted authority: String,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCalendar: LocalCalendar,
|
||||
@Assisted collection: Collection
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(
|
||||
account,
|
||||
accountSettings,
|
||||
httpClient,
|
||||
extras,
|
||||
authority,
|
||||
syncResult,
|
||||
localCalendar,
|
||||
collection
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun calendarSyncManager(
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Array<String>,
|
||||
httpClient: HttpClient,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar,
|
||||
collection: Collection
|
||||
): CalendarSyncManager
|
||||
}
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||
|
||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||
localCollection.processDirtyExceptions()
|
||||
|
||||
// now find dirty events that have no instances and set them to deleted
|
||||
localCollection.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? =
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Calendar accepts events up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
}
|
||||
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Calendar supports Collection Sync: $hasCollectionSync")
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() =
|
||||
if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
else
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
if (localCollection.readOnly) {
|
||||
var modified = false
|
||||
for (event in localCollection.findDeleted()) {
|
||||
logger.warning("Restoring locally deleted event (read-only calendar!)")
|
||||
SyncException.wrapWithLocalResource(event) {
|
||||
event.resetDeleted()
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
|
||||
// it's not enough to force synchronization (by returning true),
|
||||
// but we also need to make sure all events are downloaded again.
|
||||
if (modified)
|
||||
localCollection.lastSyncState = null
|
||||
|
||||
return modified
|
||||
}
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
return super.processLocallyDeleted()
|
||||
}
|
||||
|
||||
override fun uploadDirty(): Boolean {
|
||||
var modified = false
|
||||
if (localCollection.readOnly) {
|
||||
for (event in localCollection.findDirty()) {
|
||||
logger.warning("Resetting locally modified event to ETag=null (read-only calendar!)")
|
||||
SyncException.wrapWithLocalResource(event) {
|
||||
event.clearDirty(null, null)
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
|
||||
// it's not enough to force synchronization (by returning true),
|
||||
// but we also need to make sure all events are downloaded again.
|
||||
if (modified)
|
||||
localCollection.lastSyncState = null
|
||||
}
|
||||
|
||||
// generate UID/file name for newly created events
|
||||
val superModified = super.uploadDirty()
|
||||
|
||||
// return true when any operation returned true
|
||||
return modified or superModified
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalEvent): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
val event = requireNotNull(resource.event)
|
||||
logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: MultiResponseCallback) {
|
||||
// calculate time range limits
|
||||
val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||
ZonedDateTime.now().minusDays(pastDays.toLong()).toInstant()
|
||||
}
|
||||
|
||||
return SyncException.wrapWithRemoteResource(collection.url) {
|
||||
logger.info("Querying events since $limitStart")
|
||||
davCollection.calendarQuery(Component.VEVENT, limitStart, null, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
logger.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
davCollection.multiget(bunch) { response, _ ->
|
||||
/*
|
||||
* Real-world servers may return:
|
||||
*
|
||||
* - unrelated resources
|
||||
* - the collection itself
|
||||
* - the requested resources, but with a different collection URL (for instance, `/cal/1.ics` instead of `/shared-cal/1.ics`).
|
||||
*
|
||||
* So we:
|
||||
*
|
||||
* - ignore unsuccessful responses,
|
||||
* - ignore responses without requested calendar data (should also ignore collections and hopefully unrelated resources), and
|
||||
* - take the last segment of the href as the file name and assume that it's in the requested collection.
|
||||
*/
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
|
||||
if (!response.isSuccess()) {
|
||||
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val iCal = response[CalendarData::class.java]?.iCalendar
|
||||
if (iCal == null) {
|
||||
logger.warning("Ignoring multi-get response without calendar-data")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
||||
|
||||
processVEvent(
|
||||
response.href.lastSegment,
|
||||
eTag,
|
||||
scheduleTag,
|
||||
StringReader(iCal)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
||||
val events: List<Event>
|
||||
try {
|
||||
events = Event.eventsFromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (events.size == 1) {
|
||||
val event = events.first()
|
||||
|
||||
// set default reminder for non-full-day events, if requested
|
||||
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
|
||||
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
|
||||
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
|
||||
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
|
||||
// Needed for calendars to actually show a notification.
|
||||
properties += Action.DISPLAY
|
||||
}
|
||||
logger.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
|
||||
event.alarms += alarm
|
||||
}
|
||||
|
||||
// update local event, if it exists
|
||||
val local = localCollection.findByName(fileName)
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
if (local != null) {
|
||||
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
|
||||
local.eTag = eTag
|
||||
local.scheduleTag = scheduleTag
|
||||
local.update(event)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
|
||||
val newLocal = LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else
|
||||
logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_event)
|
||||
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.ContactsContract
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
@Inject
|
||||
lateinit var syncAdapter: Provider<SyncAdapter>
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return syncAdapter.get().syncAdapterBinder
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the sync adapter framework.
|
||||
*
|
||||
* Handles incoming sync requests from the sync adapter framework.
|
||||
*
|
||||
* Although we do not use the sync adapter for syncing anymore, we keep this sole
|
||||
* adapter to provide exported services, which allow android system components and calendar,
|
||||
* contacts or task apps to sync via DAVx5.
|
||||
*/
|
||||
class SyncAdapter @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
context,
|
||||
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
|
||||
// However, if it is -1 by accident, set it to 1 to avoid endless sync loops.
|
||||
) {
|
||||
|
||||
/**
|
||||
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
|
||||
* requests cancellation.
|
||||
*/
|
||||
private val waitScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
|
||||
|
||||
// If we should sync an address book account - find the account storing the settings
|
||||
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
|
||||
AccountManager.get(context)
|
||||
.getUserData(accountOrAddressBookAccount, USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
accountOrAddressBookAccount
|
||||
|
||||
if (account == null) {
|
||||
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
|
||||
return
|
||||
}
|
||||
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
}
|
||||
|
||||
val syncConditions = syncConditionsFactory.create(accountSettings)
|
||||
// Should we run the sync at all?
|
||||
if (!syncConditions.wifiConditionsMet()) {
|
||||
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
|
||||
return
|
||||
}
|
||||
|
||||
/* Special case for contacts: because address books are separate accounts, changed contacts cause
|
||||
this method to be called with authority = ContactsContract.AUTHORITY. However the sync worker shall be run for the
|
||||
address book authority instead. */
|
||||
val workerAuthority =
|
||||
if (authority == ContactsContract.AUTHORITY)
|
||||
context.getString(R.string.address_books_authority)
|
||||
else
|
||||
authority
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $workerAuthority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, authority = workerAuthority, upload = upload)
|
||||
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
has finished. */
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
|
||||
if (info.any { it.state.isFinished })
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
waitJob.join() // wait until worker has finished
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.info("Returning to sync framework.")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
logger.log(Level.WARNING, "Security exception for $account/$authority")
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
waitScope.cancel()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// exported sync adapter services; we need a separate class for each authority
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
|
||||
@AndroidEntryPoint
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
|
||||
@AndroidEntryPoint
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TasksOrgSyncAdapterService: SyncAdapterService()
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.ThreadFactory
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Creates a [CoroutineDispatcher] with multiple threads that guarantees that the threads
|
||||
* have set their contextClassLoader to the application context's class loader.
|
||||
*
|
||||
* We use our own dispatcher to
|
||||
*
|
||||
* - make sure that all threads have [Thread.getContextClassLoader] set, which is required for ical4j (because it uses [ServiceLoader]),
|
||||
* - control the global number of sync threads.
|
||||
*/
|
||||
@Singleton
|
||||
class SyncDispatcher @Inject constructor(
|
||||
context: Application
|
||||
) {
|
||||
|
||||
val dispatcher = createDispatcher(context.classLoader)
|
||||
|
||||
private fun createDispatcher(classLoader: ClassLoader): CoroutineDispatcher =
|
||||
ThreadPoolExecutor(
|
||||
0, Runtime.getRuntime().availableProcessors(),
|
||||
10, TimeUnit.SECONDS, LinkedBlockingQueue(),
|
||||
object: ThreadFactory {
|
||||
val group = ThreadGroup("sync-work")
|
||||
override fun newThread(r: Runnable) =
|
||||
Thread(group, r).apply {
|
||||
contextClassLoader = classLoader
|
||||
}
|
||||
}
|
||||
).asCoroutineDispatcher()
|
||||
|
||||
}
|
||||
@@ -1,924 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.DeadObjectException
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Error
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.ConflictException
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.ForbiddenException
|
||||
import at.bitfire.dav4jvm.exception.GoneException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||
import at.bitfire.dav4jvm.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4jvm.exception.ServiceUnavailableException
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import com.google.common.base.Ascii
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.cert.CertificateException
|
||||
import java.time.Instant
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
/**
|
||||
* Synchronizes a local collection with a remote collection.
|
||||
*
|
||||
* @param ResourceType type of local resources
|
||||
* @param CollectionType type of local collection
|
||||
* @param RemoteType type of remote collection
|
||||
*
|
||||
* @param account account to synchronize
|
||||
* @param accountSettings account settings of account to synchronize
|
||||
* @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account]
|
||||
* @param extras additional sync parameters
|
||||
* @param authority authority of the content provider the collection shall be synchronized with
|
||||
* @param syncResult receiver for result of the synchronization (will be updated by [performSync])
|
||||
* @param localCollection local collection to synchronize (interface to content provider)
|
||||
* @param collection collection info in the database
|
||||
*/
|
||||
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
val account: Account,
|
||||
val accountSettings: AccountSettings,
|
||||
val httpClient: HttpClient,
|
||||
val extras: Array<String>,
|
||||
val authority: String,
|
||||
val syncResult: SyncResult,
|
||||
val localCollection: CollectionType,
|
||||
val collection: Collection
|
||||
) {
|
||||
|
||||
enum class SyncAlgorithm {
|
||||
PROPFIND_REPORT,
|
||||
COLLECTION_SYNC
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** Maximum number of resources that are requested with one multiget request. */
|
||||
const val MAX_MULTIGET_RESOURCES = 10
|
||||
|
||||
const val DELAY_UNTIL_DEFAULT = 15*60L // 15 min
|
||||
const val DELAY_UNTIL_MIN = 1*60L // 1 min
|
||||
const val DELAY_UNTIL_MAX = 2*60*60L // 2 hours
|
||||
|
||||
/**
|
||||
* Returns appropriate sync retry delay in seconds, considering the servers suggestion
|
||||
* ([DELAY_UNTIL_DEFAULT] if no server suggestion).
|
||||
*
|
||||
* Takes current time into account to calculate intervals. Interval
|
||||
* will be restricted to values between [DELAY_UNTIL_MIN] and [DELAY_UNTIL_MAX].
|
||||
*
|
||||
* @param retryAfter optional server suggestion on how long to wait before retrying
|
||||
* @return until when to wait before sync can be retried
|
||||
*/
|
||||
fun getDelayUntil(retryAfter: Instant?): Instant {
|
||||
val now = Instant.now()
|
||||
|
||||
if (retryAfter == null)
|
||||
return now.plusSeconds(DELAY_UNTIL_DEFAULT)
|
||||
|
||||
// take server suggestion, but restricted to plausible min/max values
|
||||
val min = now.plusSeconds(DELAY_UNTIL_MIN)
|
||||
val max = now.plusSeconds(DELAY_UNTIL_MAX)
|
||||
return when {
|
||||
min > retryAfter -> min
|
||||
max < retryAfter -> max
|
||||
else -> retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var notificationRegistry: NotificationRegistry
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncStatsRepository: DavSyncStatsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
|
||||
init {
|
||||
// required for ServiceLoader -> ical4j -> ical4android
|
||||
Ical4Android.checkThreadContextClassLoader()
|
||||
}
|
||||
|
||||
protected val notificationTag = localCollection.tag
|
||||
|
||||
protected lateinit var davCollection: RemoteType
|
||||
|
||||
protected var hasCollectionSync = false
|
||||
|
||||
|
||||
fun performSync() {
|
||||
// dismiss previous error notifications
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
nm.cancel(notificationTag, NotificationRegistry.NOTIFY_SYNC_ERROR)
|
||||
|
||||
try {
|
||||
logger.info("Preparing synchronization")
|
||||
if (!prepare()) {
|
||||
logger.info("No reason to synchronize, aborting")
|
||||
return
|
||||
}
|
||||
syncStatsRepository.logSyncTime(collection.id, authority)
|
||||
|
||||
logger.info("Querying server capabilities")
|
||||
var remoteSyncState = queryCapabilities()
|
||||
|
||||
logger.info("Processing local deletes/updates")
|
||||
val modificationsPresent =
|
||||
processLocallyDeleted() or uploadDirty() // bitwise OR guarantees that both expressions are evaluated
|
||||
|
||||
if (extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC)) {
|
||||
logger.info("Forcing re-synchronization of all entries")
|
||||
|
||||
// forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC)
|
||||
localCollection.lastSyncState = null
|
||||
remoteSyncState = null
|
||||
|
||||
// forget sync state of members (→ download all members again and update them locally)
|
||||
localCollection.forgetETags()
|
||||
}
|
||||
|
||||
if (modificationsPresent || syncRequired(remoteSyncState))
|
||||
when (syncAlgorithm()) {
|
||||
SyncAlgorithm.PROPFIND_REPORT -> {
|
||||
logger.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
|
||||
resetPresentRemotely()
|
||||
|
||||
// get current sync state
|
||||
if (modificationsPresent)
|
||||
remoteSyncState = querySyncState()
|
||||
|
||||
// list and process all entries at current sync state (which may be the same as or newer than remoteSyncState)
|
||||
logger.info("Processing remote entries")
|
||||
syncRemote { callback ->
|
||||
listAllRemote(callback)
|
||||
}
|
||||
|
||||
logger.info("Deleting entries which are not present remotely anymore")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
logger.info("Post-processing")
|
||||
postProcess()
|
||||
|
||||
logger.log(Level.INFO, "Saving sync state", remoteSyncState)
|
||||
localCollection.lastSyncState = remoteSyncState
|
||||
}
|
||||
|
||||
SyncAlgorithm.COLLECTION_SYNC -> {
|
||||
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
|
||||
|
||||
var initialSync = false
|
||||
if (syncState == null) {
|
||||
logger.info("Starting initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
} else if (syncState.initialSync == true) {
|
||||
logger.info("Continuing initial sync")
|
||||
initialSync = true
|
||||
}
|
||||
|
||||
var furtherChanges = false
|
||||
do {
|
||||
logger.info("Listing changes since $syncState")
|
||||
syncRemote { callback ->
|
||||
try {
|
||||
val result = listRemoteChanges(syncState, callback)
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} catch (e: HttpException) {
|
||||
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
|
||||
logger.info("Sync token invalid, performing initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
|
||||
val result = listRemoteChanges(null, callback)
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
|
||||
logger.info("Server has further changes: $furtherChanges")
|
||||
} while (furtherChanges)
|
||||
|
||||
if (initialSync) {
|
||||
// initial sync is finished, remove all local resources which have not been listed by server
|
||||
logger.info("Deleting local resources which are not on server (anymore)")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
// remove initial sync flag
|
||||
syncState!!.initialSync = false
|
||||
logger.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
}
|
||||
|
||||
logger.info("Post-processing")
|
||||
postProcess()
|
||||
}
|
||||
}
|
||||
else
|
||||
logger.info("Remote collection didn't change, no reason to sync")
|
||||
|
||||
} catch (potentiallyWrappedException: Throwable) {
|
||||
var local: LocalResource<*>? = null
|
||||
var remote: HttpUrl? = null
|
||||
|
||||
val e = SyncException.unwrap(potentiallyWrappedException) {
|
||||
local = it.localResource
|
||||
remote = it.remoteResource
|
||||
}
|
||||
|
||||
when (e) {
|
||||
// DeadObjectException (may occur when syncing takes too long and process is demoted to cached):
|
||||
// re-throw to base Syncer → will cause soft error and restart the sync process
|
||||
is DeadObjectException ->
|
||||
throw e
|
||||
|
||||
// sync was cancelled or account has been removed: re-throw to BaseSyncer
|
||||
is InterruptedException,
|
||||
is InterruptedIOException,
|
||||
is InvalidAccountException ->
|
||||
throw e
|
||||
|
||||
// specific I/O errors
|
||||
is SSLHandshakeException -> {
|
||||
logger.log(Level.WARNING, "SSL handshake failed", e)
|
||||
|
||||
// when a certificate is rejected by cert4android, the cause will be a CertificateException
|
||||
if (e.cause !is CertificateException)
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
|
||||
// specific HTTP errors
|
||||
is ServiceUnavailableException -> {
|
||||
logger.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
// determine when to retry
|
||||
syncResult.delayUntil = getDelayUntil(e.retryAfter).epochSecond
|
||||
syncResult.stats.numServiceUnavailableExceptions++ // Indicate a soft error occurred
|
||||
}
|
||||
|
||||
// all others
|
||||
else ->
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prepares synchronization. Sets the lateinit property [davCollection].
|
||||
*
|
||||
* @return whether synchronization shall be performed
|
||||
*/
|
||||
protected abstract fun prepare(): Boolean
|
||||
|
||||
/**
|
||||
* Queries the server for synchronization capabilities like specific report types,
|
||||
* data formats etc.
|
||||
*
|
||||
* Should also query and save the initial sync state (e.g. CTag/sync-token).
|
||||
*
|
||||
* @return current sync state
|
||||
*/
|
||||
protected abstract fun queryCapabilities(): SyncState?
|
||||
|
||||
/**
|
||||
* Processes locally deleted entries. This can mean:
|
||||
*
|
||||
* - forwarding them to the server (HTTP `DELETE`)
|
||||
* - resetting their local state so that they will be downloaded again because they're read-only
|
||||
*
|
||||
* @return whether local resources have been processed so that a synchronization is always necessary
|
||||
*/
|
||||
protected open fun processLocallyDeleted(): Boolean {
|
||||
var numDeleted = 0
|
||||
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.findDeleted()
|
||||
for (local in localList) {
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
logger.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)")
|
||||
|
||||
val url = collection.url.newBuilder().addPathSegment(fileName).build()
|
||||
val remote = DavResource(httpClient.okHttpClient, url)
|
||||
SyncException.wrapWithRemoteResource(url) {
|
||||
try {
|
||||
remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {}
|
||||
numDeleted++
|
||||
} catch (e: HttpException) {
|
||||
logger.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
}
|
||||
}
|
||||
} else
|
||||
logger.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
}
|
||||
logger.info("Removed $numDeleted record(s) from server")
|
||||
return numDeleted > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes locally modified resources to the server. This can mean:
|
||||
*
|
||||
* - uploading them to the server (HTTP `PUT`)
|
||||
* - resetting their local state so that they will be downloaded again because they're read-only
|
||||
*
|
||||
* @return whether local resources have been processed so that a synchronization is always necessary
|
||||
*/
|
||||
protected open fun uploadDirty(): Boolean {
|
||||
var numUploaded = 0
|
||||
|
||||
// upload dirty resources (parallelized)
|
||||
runBlocking {
|
||||
for (local in localCollection.findDirty())
|
||||
launch {
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
uploadDirty(local)
|
||||
numUploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
syncResult.stats.numEntries += numUploaded
|
||||
logger.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
protected fun uploadDirty(local: ResourceType) {
|
||||
val existingFileName = local.fileName
|
||||
|
||||
var newFileName: String? = null
|
||||
var eTag: String? = null
|
||||
var scheduleTag: String? = null
|
||||
val readTagsFromResponse: (okhttp3.Response) -> Unit = { response ->
|
||||
eTag = GetETag.fromResponse(response)?.eTag
|
||||
scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
|
||||
}
|
||||
|
||||
try {
|
||||
if (existingFileName == null) { // new resource
|
||||
newFileName = local.prepareForUpload()
|
||||
|
||||
val uploadUrl = collection.url.newBuilder().addPathSegment(newFileName).build()
|
||||
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
|
||||
SyncException.wrapWithRemoteResource(uploadUrl) {
|
||||
logger.info("Uploading new record ${local.id} -> $newFileName")
|
||||
remote.put(generateUpload(local), ifNoneMatch = true, callback = readTagsFromResponse)
|
||||
}
|
||||
|
||||
} else /* existingFileName != null */ { // updated resource
|
||||
local.prepareForUpload()
|
||||
|
||||
val uploadUrl = collection.url.newBuilder().addPathSegment(existingFileName).build()
|
||||
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
|
||||
SyncException.wrapWithRemoteResource(uploadUrl) {
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
logger.info("Uploading modified record ${local.id} -> $existingFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)")
|
||||
remote.put(generateUpload(local), ifETag = lastETag, ifScheduleTag = lastScheduleTag, callback = readTagsFromResponse)
|
||||
}
|
||||
}
|
||||
} catch (e: SyncException) {
|
||||
when (val ex = e.cause) {
|
||||
is ForbiddenException -> {
|
||||
// HTTP 403 Forbidden
|
||||
// If and only if the upload failed because of missing permissions, treat it like 412.
|
||||
if (ex.errors.contains(Error.NEED_PRIVILEGES))
|
||||
logger.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
is NotFoundException, is GoneException -> {
|
||||
// HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore)
|
||||
if (local.scheduleTag != null || local.eTag != null) { // this was an update of a previously existing resource
|
||||
logger.info("Original version of locally modified resource is not there (anymore), trying as fresh upload")
|
||||
if (local.scheduleTag != null) // contacts don't support scheduleTag, don't try to set it without check
|
||||
local.scheduleTag = null
|
||||
local.eTag = null
|
||||
uploadDirty(local) // if this fails with 404, too, the collection is gone
|
||||
return
|
||||
} else
|
||||
throw e // the collection is probably gone
|
||||
}
|
||||
is ConflictException -> {
|
||||
// HTTP 409 Conflict
|
||||
// We can't interact with the user to resolve the conflict, so we treat 409 like 412.
|
||||
logger.info("Edit conflict, ignoring")
|
||||
}
|
||||
is PreconditionFailedException -> {
|
||||
// HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile.
|
||||
// Ignore this condition so that the resource can be downloaded and reset again.
|
||||
logger.info("Resource has been modified on the server before upload, ignoring")
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (eTag != null)
|
||||
logger.fine("Received new ETag=$eTag after uploading")
|
||||
else
|
||||
logger.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
|
||||
local.clearDirty(newFileName, eTag, scheduleTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the request body (iCalendar or vCard) from a local resource.
|
||||
*
|
||||
* @param resource local resource to generate the body from
|
||||
*
|
||||
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
|
||||
*/
|
||||
protected abstract fun generateUpload(resource: ResourceType): RequestBody
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether a sync is required because there were changes on the server.
|
||||
* For instance, this method can compare the collection's `CTag`/`sync-token` with
|
||||
* the last known local value.
|
||||
*
|
||||
* When local changes have been uploaded ([processLocallyDeleted] and/or
|
||||
* [uploadDirty] were true), a sync is always required and this method
|
||||
* should *not* be evaluated.
|
||||
*
|
||||
* Will return _true_ if [Syncer.SYNC_EXTRAS_RESYNC] and/or
|
||||
* [Syncer.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
|
||||
*
|
||||
* @param state remote sync state to compare local sync state with
|
||||
*
|
||||
* @return whether data has been changed on the server, i.e. whether running the
|
||||
* sync algorithm is required
|
||||
*/
|
||||
protected open fun syncRequired(state: SyncState?): Boolean {
|
||||
if (extras.contains(Syncer.SYNC_EXTRAS_RESYNC) ||
|
||||
extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC))
|
||||
return true
|
||||
|
||||
val localState = localCollection.lastSyncState
|
||||
logger.info("Local sync state = $localState, remote sync state = $state")
|
||||
return when (state?.type) {
|
||||
SyncState.Type.SYNC_TOKEN -> {
|
||||
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
|
||||
lastKnownToken != state.value
|
||||
}
|
||||
SyncState.Type.CTAG -> {
|
||||
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
|
||||
lastKnownCTag != state.value
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which sync algorithm to use.
|
||||
* @return
|
||||
* - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV
|
||||
* PROPFIND or specific REPORT requests), then compare and synchronize
|
||||
* - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578)
|
||||
*/
|
||||
protected abstract fun syncAlgorithm(): SyncAlgorithm
|
||||
|
||||
/**
|
||||
* Marks all local resources which shall be taken into consideration for this
|
||||
* sync as "synchronizing". Purpose of marking is that resources which have been marked
|
||||
* and are not present remotely anymore can be deleted.
|
||||
*
|
||||
* Used together with [deleteNotPresentRemotely].
|
||||
*/
|
||||
protected open fun resetPresentRemotely() {
|
||||
val number = localCollection.markNotDirty(0)
|
||||
logger.info("Number of local non-dirty entries: $number")
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a callback to list remote resources. All resources from the returned
|
||||
* list are downloaded and processed.
|
||||
*
|
||||
* @param listRemote function to list remote resources (for instance, all since a certain sync-token)
|
||||
*/
|
||||
protected open fun syncRemote(listRemote: (MultiResponseCallback) -> Unit) {
|
||||
// thread-safe sync stats
|
||||
val nInserted = AtomicInteger()
|
||||
val nUpdated = AtomicInteger()
|
||||
val nDeleted = AtomicInteger()
|
||||
val nSkipped = AtomicInteger()
|
||||
|
||||
runBlocking {
|
||||
// download queue
|
||||
val toDownload = LinkedBlockingQueue<HttpUrl>()
|
||||
fun download(url: HttpUrl?) {
|
||||
if (url != null)
|
||||
toDownload += url
|
||||
|
||||
if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) {
|
||||
while (toDownload.size > 0) {
|
||||
val bunch = LinkedList<HttpUrl>()
|
||||
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
|
||||
launch {
|
||||
downloadRemote(bunch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope { // structured concurrency: blocks until all inner coroutines are finished
|
||||
listRemote { response, relation ->
|
||||
// ignore non-members
|
||||
if (relation != Response.HrefRelation.MEMBER)
|
||||
return@listRemote
|
||||
|
||||
// ignore collections
|
||||
if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true)
|
||||
return@listRemote
|
||||
|
||||
val name = response.hrefName()
|
||||
|
||||
if (response.isSuccess()) {
|
||||
logger.fine("Found remote resource: $name")
|
||||
|
||||
launch {
|
||||
val local = localCollection.findByName(name)
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
if (local == null) {
|
||||
logger.info("$name has been added remotely, queueing download")
|
||||
download(response.href)
|
||||
nInserted.incrementAndGet()
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag) {
|
||||
logger.info("$name has not been changed on server (ETag still $remoteETag)")
|
||||
nSkipped.incrementAndGet()
|
||||
} else {
|
||||
logger.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
download(response.href)
|
||||
nUpdated.incrementAndGet()
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
// collection sync: resource has been deleted on remote server
|
||||
launch {
|
||||
localCollection.findByName(name)?.let { local ->
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
logger.info("$name has been deleted on server, deleting locally")
|
||||
local.delete()
|
||||
nDeleted.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// download remaining resources
|
||||
download(null)
|
||||
}
|
||||
|
||||
// update sync stats
|
||||
with(syncResult.stats) {
|
||||
numInserts += nInserted.get()
|
||||
numUpdates += nUpdated.get()
|
||||
numDeletes += nDeleted.get()
|
||||
numSkippedEntries += nSkipped.get()
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun listAllRemote(callback: MultiResponseCallback)
|
||||
|
||||
protected open fun listRemoteChanges(syncState: SyncState?, callback: MultiResponseCallback): Pair<SyncToken, Boolean> {
|
||||
var furtherResults = false
|
||||
|
||||
val report = davCollection.reportChanges(
|
||||
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME) { response, relation ->
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF ->
|
||||
furtherResults = response.status?.code == 507
|
||||
|
||||
Response.HrefRelation.MEMBER ->
|
||||
callback.onResponse(response, relation)
|
||||
|
||||
else ->
|
||||
logger.fine("Unexpected sync-collection response: $response")
|
||||
}
|
||||
}
|
||||
|
||||
var syncToken: SyncToken? = null
|
||||
report.filterIsInstance(SyncToken::class.java).firstOrNull()?.let {
|
||||
syncToken = it
|
||||
}
|
||||
if (syncToken == null)
|
||||
throw DavException("Received sync-collection response without sync-token")
|
||||
|
||||
return Pair(syncToken!!, furtherResults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and processes resources, given as a list of URLs. Will be called with a list
|
||||
* of changed/new remote resources.
|
||||
*
|
||||
* Implementations should not use GET to fetch single resources, but always multi-get, even
|
||||
* for single resources for these reasons:
|
||||
*
|
||||
* 1. GET can only be used without HTTP compression, because it may change the ETag.
|
||||
* multi-get sends the ETag in the XML body, so there's no problem with compression.
|
||||
* 2. Some servers are wrongly configured to suppress the ETag header in the response.
|
||||
* With multi-get, the ETag is in the XML body, so it won't be affected by that.
|
||||
* 3. If there are two methods to download resources (GET and multi-get), both methods
|
||||
* have to be implemented, tested and maintained. Given that multi-get is required
|
||||
* in any case, it's better to have only one method.
|
||||
* 4. For users, it's strange behavior when DAVx5 can download multiple remote changes,
|
||||
* but not a single one (or vice versa). So only one method is more user-friendly.
|
||||
* 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests.
|
||||
*/
|
||||
protected abstract fun downloadRemote(bunch: List<HttpUrl>)
|
||||
|
||||
/**
|
||||
* Locally deletes entries which are
|
||||
* 1. not dirty and
|
||||
* 2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT].
|
||||
*
|
||||
* Used together with [resetPresentRemotely] when a full listing has been received from
|
||||
* the server to locally delete resources which are not present remotely (anymore).
|
||||
*/
|
||||
protected open fun deleteNotPresentRemotely() {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
logger.info("Removed $removed local resources which are not present on the server anymore")
|
||||
syncResult.stats.numDeletes += removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing of synchronized entries, for instance contact group membership operations.
|
||||
*/
|
||||
protected abstract fun postProcess()
|
||||
|
||||
|
||||
// sync helpers
|
||||
|
||||
protected fun syncState(dav: Response) =
|
||||
dav[SyncToken::class.java]?.token?.let {
|
||||
SyncState(SyncState.Type.SYNC_TOKEN, it)
|
||||
} ?:
|
||||
dav[GetCTag::class.java]?.cTag?.let {
|
||||
SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
|
||||
private fun querySyncState(): SyncState? {
|
||||
var state: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
state = syncState(response)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
|
||||
// exception helpers
|
||||
|
||||
private fun notifyException(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = notificationTag) {
|
||||
val message: String
|
||||
|
||||
when (e) {
|
||||
is IOException -> {
|
||||
logger.log(Level.WARNING, "I/O error", e)
|
||||
message = context.getString(R.string.sync_error_io, e.localizedMessage)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
|
||||
is UnauthorizedException -> {
|
||||
logger.log(Level.SEVERE, "Not authorized anymore", e)
|
||||
message = context.getString(R.string.sync_error_authentication_failed)
|
||||
syncResult.stats.numAuthExceptions++
|
||||
}
|
||||
|
||||
is HttpException, is DavException -> {
|
||||
logger.log(Level.SEVERE, "HTTP/DAV exception", e)
|
||||
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
|
||||
syncResult.stats.numHttpExceptions++
|
||||
}
|
||||
|
||||
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
|
||||
logger.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
|
||||
syncResult.localStorageError = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.log(Level.SEVERE, "Unclassified sync error", e)
|
||||
message = e.localizedMessage ?: e::class.java.simpleName
|
||||
syncResult.stats.numUnclassifiedErrors++
|
||||
}
|
||||
}
|
||||
|
||||
val contentIntent: Intent
|
||||
var viewItemAction: NotificationCompat.Action? = null
|
||||
if (e is UnauthorizedException) {
|
||||
contentIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
contentIntent.putExtra(
|
||||
AccountSettingsActivity.EXTRA_ACCOUNT,
|
||||
account
|
||||
)
|
||||
} else {
|
||||
contentIntent = buildDebugInfoIntent(e, local, remote)
|
||||
if (local != null)
|
||||
viewItemAction = buildViewItemAction(local)
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
contentIntent.data = Uri.parse("davdroid:exception/${e.hashCode()}")
|
||||
|
||||
val channel: String
|
||||
val priority: Int
|
||||
if (e is IOException) {
|
||||
channel = notificationRegistry.CHANNEL_SYNC_IO_ERRORS
|
||||
priority = NotificationCompat.PRIORITY_MIN
|
||||
} else {
|
||||
channel = notificationRegistry.CHANNEL_SYNC_ERRORS
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, channel)
|
||||
builder.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(localCollection.title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
|
||||
.setSubText(account.name)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.setPriority(priority)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
viewItemAction?.let { builder.addAction(it) }
|
||||
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDebugInfoIntent(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?): Intent {
|
||||
val builder = DebugInfoActivity.IntentBuilder(context)
|
||||
.withAccount(account)
|
||||
.withAuthority(authority)
|
||||
.withCause(e)
|
||||
|
||||
if (local != null)
|
||||
try {
|
||||
// Truncate the string to avoid the Intent to be > 1 MB, which doesn't work (IPC limit)
|
||||
builder.withLocalResource(Ascii.truncate(local.toString(), 10000, "[…]"))
|
||||
} catch (_: OutOfMemoryError) {
|
||||
// For instance because of a huge contact photo; maybe we're lucky and can catch it
|
||||
}
|
||||
|
||||
if (remote != null)
|
||||
builder.withRemoteResource(remote)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildViewItemAction(local: LocalResource<*>): NotificationCompat.Action? {
|
||||
logger.log(Level.FINE, "Adding view action for local resource", local)
|
||||
val intent = local.id?.let { id ->
|
||||
when (local) {
|
||||
is LocalContact ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
|
||||
is LocalEvent ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
|
||||
is LocalTask ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
|
||||
else ->
|
||||
null
|
||||
}
|
||||
}
|
||||
return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
|
||||
NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item),
|
||||
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
protected fun notifyInvalidResource(e: Throwable, fileName: String) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_INVALID_RESOURCE, tag = notificationTag) {
|
||||
val intent = buildDebugInfoIntent(e, null, collection.url.resolve(fileName))
|
||||
|
||||
val builder = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_SYNC_WARNINGS)
|
||||
builder.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(notifyInvalidResourceTitle())
|
||||
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
|
||||
.setSubText(account.name)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.priority = NotificationCompat.PRIORITY_LOW
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun notifyInvalidResourceTitle(): String
|
||||
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
/**
|
||||
* This class represents the results of a sync operation from [Syncer].
|
||||
*
|
||||
* Used by [at.bitfire.davdroid.sync.worker.BaseSyncWorker] to determine whether or not there will be retries etc.
|
||||
*/
|
||||
data class SyncResult(
|
||||
var contentProviderError: Boolean = false,
|
||||
var localStorageError: Boolean = false,
|
||||
var delayUntil: Long = 0,
|
||||
val stats: SyncStats = SyncStats()
|
||||
) {
|
||||
|
||||
/**
|
||||
* Whether a hard error occurred.
|
||||
*/
|
||||
fun hasHardError(): Boolean =
|
||||
contentProviderError
|
||||
|| localStorageError
|
||||
|| stats.numAuthExceptions > 0
|
||||
|| stats.numHttpExceptions > 0
|
||||
|| stats.numUnclassifiedErrors > 0
|
||||
|
||||
/**
|
||||
* Whether a soft error occurred.
|
||||
*/
|
||||
fun hasSoftError(): Boolean =
|
||||
stats.numDeadObjectExceptions > 0
|
||||
|| stats.numIoExceptions > 0
|
||||
|| stats.numServiceUnavailableExceptions > 0
|
||||
|
||||
/**
|
||||
* Whether a hard or a soft error occurred.
|
||||
*/
|
||||
fun hasError(): Boolean =
|
||||
hasHardError() || hasSoftError()
|
||||
|
||||
/**
|
||||
* Holds statistics about the sync operation. Used to determine retries. Also useful for
|
||||
* debugging and customer support when logged.
|
||||
*/
|
||||
data class SyncStats(
|
||||
// Stats
|
||||
var numDeletes: Long = 0,
|
||||
var numEntries: Long = 0,
|
||||
var numInserts: Long = 0,
|
||||
var numSkippedEntries: Long = 0,
|
||||
var numUpdates: Long = 0,
|
||||
|
||||
// Hard errors
|
||||
var numAuthExceptions: Long = 0,
|
||||
var numHttpExceptions: Long = 0,
|
||||
var numUnclassifiedErrors: Long = 0,
|
||||
|
||||
// Soft errors
|
||||
var numDeadObjectExceptions: Long = 0,
|
||||
var numIoExceptions: Long = 0,
|
||||
var numServiceUnavailableExceptions: Long = 0
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.R
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Utility methods related to synchronization management (authorities, workers etc.)
|
||||
*/
|
||||
object SyncUtils {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncUtilsEntryPoint {
|
||||
fun tasksAppManager(): TasksAppManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all available sync authorities:
|
||||
*
|
||||
* 1. calendar authority
|
||||
* 2. address books authority
|
||||
* 3. current tasks authority (if available)
|
||||
*
|
||||
* Checking the availability of authorities may be relatively expensive, so the
|
||||
* result should be cached for the current operation.
|
||||
*
|
||||
* @return list of available sync authorities for DAVx5 accounts
|
||||
*/
|
||||
fun syncAuthorities(context: Context): List<String> {
|
||||
val result = mutableListOf(
|
||||
CalendarContract.AUTHORITY,
|
||||
context.getString(R.string.address_books_authority)
|
||||
)
|
||||
|
||||
val entryPoint = EntryPointAccessors.fromApplication<SyncUtilsEntryPoint>(context)
|
||||
val tasksAppManager = entryPoint.tasksAppManager()
|
||||
tasksAppManager.currentProvider()?.let { taskProvider ->
|
||||
result += taskProvider.authority
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationCompat
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Responsible for setting/getting the currently used tasks app, and for communicating with it.
|
||||
*/
|
||||
class TasksAppManager @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val accountRepository: Lazy<AccountRepository>,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: Lazy<NotificationRegistry>,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Gets the currently selected tasks app.
|
||||
*
|
||||
* @return currently selected tasks app, or `null` if no tasks app is selected or the selected app is not installed
|
||||
*/
|
||||
fun currentProvider(): ProviderName? {
|
||||
val preferredAuthority = settingsManager.getString(Settings.SELECTED_TASKS_PROVIDER) ?: return null
|
||||
return preferredAuthorityToProviderName(preferredAuthority)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [currentProvider, but as a [StateFlow].
|
||||
*/
|
||||
fun currentProviderFlow(externalScope: CoroutineScope): StateFlow<ProviderName?> {
|
||||
return settingsManager.getStringFlow(Settings.SELECTED_TASKS_PROVIDER).map { preferred ->
|
||||
if (preferred != null)
|
||||
preferredAuthorityToProviderName(preferred)
|
||||
else
|
||||
null
|
||||
}.stateIn(scope = externalScope, started = SharingStarted.WhileSubscribed(), initialValue = null)
|
||||
}
|
||||
|
||||
private fun preferredAuthorityToProviderName(preferredAuthority: String): ProviderName? {
|
||||
val packageManager = context.packageManager
|
||||
ProviderName.entries.toTypedArray()
|
||||
.sortedByDescending { it.authority == preferredAuthority }
|
||||
.forEach { providerName ->
|
||||
if (packageManager.resolveContentProvider(providerName.authority, 0) != null)
|
||||
return providerName
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets up sync for the current TaskProvider (and disables sync for unavailable task providers):
|
||||
*
|
||||
* 1. Makes selected tasks authority _syncable_ in the sync framework, all other authorities _not syncable_.
|
||||
* 2. Creates periodic sync worker for selected authority, disables periodic sync workers for all other authorities.
|
||||
* 3. If the permissions don't allow synchronizing with the selected tasks app, a notification is shown.
|
||||
*
|
||||
* Called
|
||||
*
|
||||
* - when a user explicitly selects another task app, or
|
||||
* - when there previously was no (usable) tasks app and [at.bitfire.davdroid.TasksAppWatcher] detected a new one.
|
||||
*/
|
||||
fun selectProvider(selectedProvider: ProviderName?) {
|
||||
logger.info("Selecting tasks app: $selectedProvider")
|
||||
|
||||
settingsManager.putString(Settings.SELECTED_TASKS_PROVIDER, selectedProvider?.authority)
|
||||
|
||||
var permissionsRequired = false // whether additional permissions are required
|
||||
|
||||
// check all accounts and (de)activate task provider(s) if a CalDAV service is defined
|
||||
for (account in accountRepository.get().getAll()) {
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
for (providerName in TaskProvider.ProviderName.entries) {
|
||||
val syncable = hasCalDAV && providerName == selectedProvider
|
||||
|
||||
// enable/disable sync for the given account and authority
|
||||
setSyncable(
|
||||
context,
|
||||
account,
|
||||
providerName.authority,
|
||||
syncable
|
||||
)
|
||||
|
||||
// if sync has just been enabled: check whether additional permissions are required
|
||||
if (syncable && !PermissionUtils.havePermissions(context, providerName.permissions))
|
||||
permissionsRequired = true
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionsRequired) {
|
||||
logger.warning("Tasks synchronization is now enabled for at least one account, but permissions are not granted")
|
||||
notificationRegistry.get().notifyPermissions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSyncable(context: Context, account: Account, authority: String, syncable: Boolean) {
|
||||
try {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
if (syncable) {
|
||||
logger.info("Enabling $authority sync for $account")
|
||||
|
||||
// make account syncable by sync framework
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// set sync interval according to settings; also updates periodic sync workers and sync framework on-content-change
|
||||
val interval = settings.getTasksSyncInterval() ?: settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
|
||||
settings.setSyncInterval(authority, interval)
|
||||
} else {
|
||||
logger.info("Disabling $authority sync for $account")
|
||||
|
||||
// make account not syncable by sync framework
|
||||
ContentResolver.setIsSyncable(account, authority, 0)
|
||||
|
||||
// disable periodic sync worker
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
}
|
||||
} catch (e: InvalidAccountException) {
|
||||
// account has already been removed, make sure periodic sync is disabled, too
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show a notification that starts an Intent and redirects the user to the tasks app in the app store.
|
||||
*
|
||||
* @param e the TaskProvider.ProviderTooOldException to be shown
|
||||
*/
|
||||
fun notifyProviderTooOld(e: TaskProvider.ProviderTooOldException) {
|
||||
val registry = notificationRegistry.get()
|
||||
registry.notifyIfPossible(NotificationRegistry.NOTIFY_TASKS_PROVIDER_TOO_OLD) {
|
||||
val message = context.getString(R.string.sync_error_tasks_required_version, e.provider.minVersionName)
|
||||
|
||||
val pm = context.packageManager
|
||||
val tasksAppInfo = pm.getPackageInfo(e.provider.packageName, 0)
|
||||
val tasksAppLabel = tasksAppInfo.applicationInfo?.loadLabel(pm)
|
||||
|
||||
val notify = NotificationCompat.Builder(context, registry.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(context.getString(R.string.sync_error_tasks_too_old, tasksAppLabel))
|
||||
.setContentText(message)
|
||||
.setSubText("$tasksAppLabel ${e.installedVersionName}")
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
|
||||
try {
|
||||
val icon = pm.getApplicationIcon(e.provider.packageName)
|
||||
if (icon is BitmapDrawable)
|
||||
notify.setLargeIcon(icon.bitmap)
|
||||
} catch (ignored: PackageManager.NameNotFoundException) {
|
||||
// couldn't get provider app icon
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}"))
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
if (intent.resolveActivity(pm) != null)
|
||||
notify.setContentIntent(PendingIntent.getActivity(context, 0, intent, flags))
|
||||
|
||||
notify.build()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.Task
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles tasks (VTODO)
|
||||
*/
|
||||
class TasksSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted accountSettings: AccountSettings,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted authority: String,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTaskList,
|
||||
@Assisted collection: Collection
|
||||
): SyncManager<LocalTask, LocalTaskList, DavCalendar>(
|
||||
account,
|
||||
accountSettings,
|
||||
httpClient,
|
||||
extras,
|
||||
authority,
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun tasksSyncManager(
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
httpClient: HttpClient,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTaskList,
|
||||
collection: Collection
|
||||
): TasksSyncManager
|
||||
}
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() =
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Calendar accepts tasks up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun generateUpload(resource: LocalTask): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
val task = requireNotNull(resource.task)
|
||||
logger.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: MultiResponseCallback) {
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
logger.info("Querying tasks")
|
||||
davCollection.calendarQuery("VTODO", null, null, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
logger.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
davCollection.multiget(bunch) { response, _ ->
|
||||
// See CalendarSyncManager for more information about the multi-get response
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
|
||||
if (!response.isSuccess()) {
|
||||
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val iCal = response[CalendarData::class.java]?.iCalendar
|
||||
if (iCal == null) {
|
||||
logger.warning("Ignoring multi-get response without calendar-data")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
processVTodo(response.href.lastSegment, eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
val touched = localCollection.touchRelations()
|
||||
logger.info("Touched $touched relations")
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
|
||||
val tasks: List<Task>
|
||||
try {
|
||||
tasks = Task.tasksFromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (tasks.size == 1) {
|
||||
val newData = tasks.first()
|
||||
|
||||
// update local task, if it exists
|
||||
val local = localCollection.findByName(fileName)
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
if (local != null) {
|
||||
logger.log(Level.INFO, "Updating $fileName in local task list", newData)
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local task list", newData)
|
||||
val newLocal = LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else
|
||||
logger.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_task)
|
||||
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
/***************************************************************************************************
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.push.PushNotificationManager
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.AddressBookSyncer
|
||||
import at.bitfire.davdroid.sync.CalendarSyncer
|
||||
import at.bitfire.davdroid.sync.JtxSyncer
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.sync.SyncResult
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.Syncer
|
||||
import at.bitfire.davdroid.sync.TaskSyncer
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class BaseSyncWorker(
|
||||
context: Context,
|
||||
private val workerParams: WorkerParameters,
|
||||
private val syncDispatcher: CoroutineDispatcher
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
// common worker input parameters
|
||||
const val INPUT_ACCOUNT_NAME = "accountName"
|
||||
const val INPUT_ACCOUNT_TYPE = "accountType"
|
||||
const val INPUT_AUTHORITY = "authority"
|
||||
|
||||
/** set to `true` for user-initiated sync that skips network checks */
|
||||
const val INPUT_MANUAL = "manual"
|
||||
|
||||
/** set to `true` for syncs that are caused by local changes */
|
||||
const val INPUT_UPLOAD = "upload"
|
||||
|
||||
/** Whether re-synchronization is requested. One of [NO_RESYNC] (default), [RESYNC] or [FULL_RESYNC]. */
|
||||
const val INPUT_RESYNC = "resync"
|
||||
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
|
||||
annotation class InputResync
|
||||
const val NO_RESYNC = 0
|
||||
/** Re-synchronization is requested. See [Syncer.SYNC_EXTRAS_RESYNC] for details. */
|
||||
const val RESYNC = 1
|
||||
/** Full re-synchronization is requested. See [Syncer.SYNC_EXTRAS_FULL_RESYNC] for details. */
|
||||
const val FULL_RESYNC = 2
|
||||
|
||||
/**
|
||||
* How often this work will be retried to run after soft (network) errors.
|
||||
*
|
||||
* Retry strategy is defined in work request ([enqueue]).
|
||||
*/
|
||||
internal const val MAX_RUN_ATTEMPTS = 5
|
||||
|
||||
/**
|
||||
* Set of currently running syncs, identified by their [commonTag].
|
||||
*/
|
||||
private val runningSyncs = Collections.synchronizedSet(HashSet<String>())
|
||||
|
||||
/**
|
||||
* Stops running sync workers and removes pending sync workers from queue, for all authorities.
|
||||
*/
|
||||
fun cancelAllWork(context: Context, account: Account) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
workManager.cancelUniqueWork(OneTimeSyncWorker.workerName(account, authority))
|
||||
workManager.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This tag shall be added to every worker that is enqueued by a subclass.
|
||||
*/
|
||||
fun commonTag(account: Account, authority: String): String =
|
||||
"sync-$authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Observes whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker])
|
||||
* exist, belonging to given account and authorities, and which are/is in the given worker state.
|
||||
*
|
||||
* @param workStates list of states of workers to match
|
||||
* @param account the account which the workers belong to
|
||||
* @param authorities type of sync work, ie [CalendarContract.AUTHORITY]
|
||||
* @param whichTag function to generate tag that should be observed for given account and authority
|
||||
*
|
||||
* @return flow that emits `true` if at least one worker with matching query was found; `false` otherwise
|
||||
*/
|
||||
fun exists(
|
||||
context: Context,
|
||||
workStates: List<WorkInfo.State>,
|
||||
account: Account? = null,
|
||||
authorities: List<String>? = null,
|
||||
whichTag: (account: Account, authority: String) -> String = { account, authority ->
|
||||
commonTag(account, authority)
|
||||
}
|
||||
): Flow<Boolean> {
|
||||
val workQuery = WorkQuery.Builder.fromStates(workStates)
|
||||
if (account != null && authorities != null)
|
||||
workQuery.addTags(
|
||||
authorities.map { authority -> whichTag(account, authority) }
|
||||
)
|
||||
return WorkManager.getInstance(context)
|
||||
.getWorkInfosFlow(workQuery.build())
|
||||
.map { workInfoList ->
|
||||
workInfoList.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var addressBookSyncer: AddressBookSyncer.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var calendarSyncer: CalendarSyncer.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var jtxSyncer: JtxSyncer.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var notificationRegistry: NotificationRegistry
|
||||
|
||||
@Inject
|
||||
lateinit var pushNotificationManager: PushNotificationManager
|
||||
|
||||
@Inject
|
||||
lateinit var syncConditionsFactory: SyncConditions.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var taskSyncer: TaskSyncer.Factory
|
||||
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// ensure we got the required arguments
|
||||
val account = Account(
|
||||
inputData.getString(INPUT_ACCOUNT_NAME) ?: throw IllegalArgumentException("$INPUT_ACCOUNT_NAME required"),
|
||||
inputData.getString(INPUT_ACCOUNT_TYPE) ?: throw IllegalArgumentException("$INPUT_ACCOUNT_TYPE required")
|
||||
)
|
||||
val authority = inputData.getString(INPUT_AUTHORITY) ?: throw IllegalArgumentException("$INPUT_AUTHORITY required")
|
||||
|
||||
val syncTag = commonTag(account, authority)
|
||||
logger.info("${javaClass.simpleName} called for $syncTag")
|
||||
|
||||
if (!runningSyncs.add(syncTag)) {
|
||||
logger.info("There's already another worker running for $syncTag, skipping")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// Dismiss any pending push notification
|
||||
pushNotificationManager.dismiss(account, authority)
|
||||
|
||||
try {
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
val workId = workerParams.id
|
||||
logger.warning("Account $account doesn't exist anymore, cancelling worker $workId")
|
||||
|
||||
val workManager = WorkManager.getInstance(applicationContext)
|
||||
workManager.cancelWorkById(workId)
|
||||
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (inputData.getBoolean(INPUT_MANUAL, false))
|
||||
logger.info("Manual sync, skipping network checks")
|
||||
else {
|
||||
val syncConditions = syncConditionsFactory.create(accountSettings)
|
||||
|
||||
// check internet connection
|
||||
if (!syncConditions.internetAvailable()) {
|
||||
logger.info("WorkManager started SyncWorker without Internet connection. Aborting.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// check WiFi restriction
|
||||
if (!syncConditions.wifiConditionsMet()) {
|
||||
logger.info("WiFi conditions not met. Won't run periodic sync.")
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
return doSyncWork(account, authority, accountSettings)
|
||||
} finally {
|
||||
logger.info("${javaClass.simpleName} finished for $syncTag")
|
||||
runningSyncs -= syncTag
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31 && stopReason != WorkInfo.STOP_REASON_NOT_STOPPED)
|
||||
logger.warning("Worker was stopped with reason: $stopReason")
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun doSyncWork(
|
||||
account: Account,
|
||||
authority: String,
|
||||
accountSettings: AccountSettings
|
||||
): Result = withContext(syncDispatcher) {
|
||||
logger.info("Running ${javaClass.name}: account=$account, authority=$authority")
|
||||
|
||||
// pass possibly supplied flags to the selected syncer
|
||||
val extrasList = mutableListOf<String>()
|
||||
when (inputData.getInt(INPUT_RESYNC, NO_RESYNC)) {
|
||||
RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_RESYNC)
|
||||
FULL_RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
|
||||
}
|
||||
if (inputData.getBoolean(INPUT_UPLOAD, false))
|
||||
// Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround.
|
||||
extrasList.add(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
val extras = extrasList.toTypedArray()
|
||||
|
||||
// We still use the sync adapter framework's SyncResult to pass the sync results, but this
|
||||
// is only for legacy reasons and can be replaced by our own result class in the future.
|
||||
val result = SyncResult()
|
||||
|
||||
// What are we going to sync? Select syncer based on authority
|
||||
val syncer = when (authority) {
|
||||
applicationContext.getString(R.string.address_books_authority) ->
|
||||
addressBookSyncer.create(account, extras, result)
|
||||
CalendarContract.AUTHORITY ->
|
||||
calendarSyncer.create(account, extras, result)
|
||||
TaskProvider.ProviderName.JtxBoard.authority ->
|
||||
jtxSyncer.create(account, extras, result)
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority ->
|
||||
taskSyncer.create(account, authority, extras, result)
|
||||
else ->
|
||||
throw IllegalArgumentException("Invalid authority $authority")
|
||||
}
|
||||
|
||||
// Start syncing
|
||||
runInterruptible {
|
||||
syncer()
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if (result.hasError()) {
|
||||
val syncResult = Data.Builder()
|
||||
.putString("syncresult", result.toString())
|
||||
.putString("syncResultStats", result.stats.toString())
|
||||
.build()
|
||||
|
||||
val softErrorNotificationTag = account.type + "-" + account.name + "-" + authority
|
||||
|
||||
// On soft errors the sync is retried a few times before considered failed
|
||||
if (result.hasSoftError()) {
|
||||
logger.warning("Soft error while syncing: result=$result, stats=${result.stats}")
|
||||
if (runAttemptCount < MAX_RUN_ATTEMPTS) {
|
||||
val blockDuration = result.delayUntil - System.currentTimeMillis() / 1000
|
||||
logger.warning("Waiting for $blockDuration seconds, before retrying ...")
|
||||
|
||||
// We block the SyncWorker here so that it won't be started by the sync framework immediately again.
|
||||
// This should be replaced by proper work scheduling as soon as we don't depend on the sync framework anymore.
|
||||
if (blockDuration > 0)
|
||||
delay(blockDuration * 1000)
|
||||
|
||||
logger.warning("Retrying on soft error (attempt $runAttemptCount of $MAX_RUN_ATTEMPTS)")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
logger.warning("Max retries on soft errors reached ($runAttemptCount of $MAX_RUN_ATTEMPTS). Treating as failed")
|
||||
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = softErrorNotificationTag) {
|
||||
NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_SYNC_IO_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(account.name)
|
||||
.setContentText(applicationContext.getString(R.string.sync_error_retry_limit_reached))
|
||||
.setSubText(account.name)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
}
|
||||
|
||||
return@withContext Result.failure(syncResult)
|
||||
}
|
||||
|
||||
// If no soft error found, dismiss sync error notification
|
||||
val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
notificationManager.cancel(
|
||||
softErrorNotificationTag,
|
||||
NotificationRegistry.NOTIFY_SYNC_ERROR
|
||||
)
|
||||
|
||||
// On a hard error - fail with an error message
|
||||
// Note: SyncManager should have notified the user
|
||||
if (result.hasHardError()) {
|
||||
logger.warning("Hard error while syncing: result=$result, stats=${result.stats}")
|
||||
return@withContext Result.failure(syncResult)
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.Constants.withStatParams
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.composable.PixelBoxes
|
||||
import com.mikepenz.aboutlibraries.Libs
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import com.mikepenz.aboutlibraries.util.withJson
|
||||
import dagger.BindsOptionalOf
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.components.ActivityComponent
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.text.Collator
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AboutActivity: AppCompatActivity() {
|
||||
|
||||
val model by viewModels<Model>()
|
||||
|
||||
@Inject
|
||||
lateinit var licenseInfoProvider: Optional<AppLicenseInfoProvider>
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onSupportNavigateUp() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowBack,
|
||||
contentDescription = stringResource(R.string.navigate_up)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.navigation_drawer_about))
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
uriHandler.openUri(Constants.HOMEPAGE_URL
|
||||
.buildUpon()
|
||||
.withStatParams("AboutActivity")
|
||||
.build().toString())
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Home,
|
||||
contentDescription = stringResource(R.string.navigation_drawer_website)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = rememberPagerState(pageCount = { 3 })
|
||||
|
||||
TabRow(state.currentPage) {
|
||||
Tab(state.currentPage == 0, onClick = {
|
||||
scope.launch { state.scrollToPage(0) }
|
||||
}) {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
Tab(state.currentPage == 1, onClick = {
|
||||
scope.launch { state.scrollToPage(1) }
|
||||
}) {
|
||||
Text(
|
||||
stringResource(R.string.about_translations),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
Tab(state.currentPage == 2, onClick = {
|
||||
scope.launch { state.scrollToPage(2) }
|
||||
}) {
|
||||
Text(
|
||||
stringResource(R.string.about_libraries),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.Top
|
||||
) { index ->
|
||||
when (index) {
|
||||
0 -> AboutApp(licenseInfoProvider = licenseInfoProvider.getOrNull())
|
||||
1 -> {
|
||||
val translations = model.translations.observeAsState(emptyList())
|
||||
TranslatorsGallery(translations.value)
|
||||
}
|
||||
|
||||
2 -> LibrariesContainer(Modifier.fillMaxSize(),
|
||||
itemContentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||
itemSpacing = 8.dp,
|
||||
librariesBlock = { ctx ->
|
||||
Libs.Builder()
|
||||
.withJson(ctx, R.raw.aboutlibraries)
|
||||
.build()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class Model @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger
|
||||
): ViewModel() {
|
||||
|
||||
data class Translation(
|
||||
val language: String,
|
||||
val translators: Set<String>
|
||||
)
|
||||
|
||||
val translations = MutableLiveData<List<Translation>>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadTranslations()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTranslations() {
|
||||
try {
|
||||
context.resources.assets.open("translators.json").use { stream ->
|
||||
val jsonTranslations = JSONObject(stream.readBytes().decodeToString())
|
||||
val result = LinkedList<Translation>()
|
||||
for (langCode in jsonTranslations.keys()) {
|
||||
val jsonTranslators = jsonTranslations.getJSONArray(langCode)
|
||||
val translators = Array<String>(jsonTranslators.length()) { idx ->
|
||||
jsonTranslators.getString(idx)
|
||||
}
|
||||
|
||||
val langTag = langCode.replace('_', '-')
|
||||
val language = Locale.forLanguageTag(langTag).displayName
|
||||
result += Translation(language, translators.toSet())
|
||||
}
|
||||
|
||||
// sort translations by localized language name
|
||||
val collator = Collator.getInstance()
|
||||
result.sortWith { o1, o2 ->
|
||||
collator.compare(o1.language, o2.language)
|
||||
}
|
||||
|
||||
translations.postValue(result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't load translators", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
interface AppLicenseInfoProvider {
|
||||
@Composable
|
||||
fun LicenseInfo()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityComponent::class)
|
||||
interface AppLicenseInfoProviderModule {
|
||||
@BindsOptionalOf
|
||||
fun appLicenseInfoProvider(): AppLicenseInfoProvider
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun AboutApp(licenseInfoProvider: AboutActivity.AppLicenseInfoProvider? = null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
Image(
|
||||
UiUtils.adaptiveIconPainterResource(R.mipmap.ic_launcher),
|
||||
contentDescription = stringResource(R.string.app_name),
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.about_copyright),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.about_license_info_no_warranty),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
|
||||
PixelBoxes(
|
||||
arrayOf(Color(0xFFFCF434), Color.White, Color(0xFF9C59D1), Color.Black),
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(16.dp)
|
||||
)
|
||||
|
||||
licenseInfoProvider?.LicenseInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun AboutApp_Preview() {
|
||||
AboutApp(licenseInfoProvider = object : AboutActivity.AppLicenseInfoProvider {
|
||||
@Composable
|
||||
override fun LicenseInfo() {
|
||||
Text("Some flavored License Info")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun TranslatorsGallery(
|
||||
translations: List<AboutActivity.Model.Translation>
|
||||
) {
|
||||
val collator = Collator.getInstance()
|
||||
LazyColumn(Modifier.padding(8.dp)) {
|
||||
items(translations) { translation ->
|
||||
Text(
|
||||
translation.language,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
Text(
|
||||
translation.translators
|
||||
.sortedWith { a, b -> collator.compare(a, b) }
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun TranslatorsGallery_Sample() {
|
||||
TranslatorsGallery(listOf(
|
||||
AboutActivity.Model.Translation("Some Language", setOf("User 1", "User 2")),
|
||||
AboutActivity.Model.Translation("Another Language", setOf("User 3", "User 4"))
|
||||
))
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.cert4android.CustomCertStore
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.repository.PreferenceRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel
|
||||
import at.bitfire.davdroid.ui.intro.OpenSourcePage
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.davdroid.util.broadcastReceiverFlow
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppSettingsModel @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val preference: PreferenceRepository,
|
||||
private val settings: SettingsManager,
|
||||
private val tasksAppManager: TasksAppManager
|
||||
) : ViewModel() {
|
||||
|
||||
// debugging
|
||||
|
||||
private val powerManager = context.getSystemService<PowerManager>()!!
|
||||
val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true)
|
||||
.map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
|
||||
fun verboseLogging() = preference.logToFileFlow()
|
||||
fun updateVerboseLogging(verbose: Boolean) {
|
||||
preference.logToFile(verbose)
|
||||
}
|
||||
|
||||
|
||||
// connection
|
||||
|
||||
fun proxyType() = settings.getIntFlow(Settings.PROXY_TYPE)
|
||||
fun updateProxyType(type: Int) {
|
||||
settings.putInt(Settings.PROXY_TYPE, type)
|
||||
}
|
||||
|
||||
fun proxyHostName() = settings.getStringFlow(Settings.PROXY_HOST)
|
||||
fun updateProxyHostName(host: String) {
|
||||
settings.putString(Settings.PROXY_HOST, host)
|
||||
}
|
||||
|
||||
fun proxyPort() = settings.getIntFlow(Settings.PROXY_PORT)
|
||||
fun updateProxyPort(port: Int) {
|
||||
settings.putInt(Settings.PROXY_PORT, port)
|
||||
}
|
||||
|
||||
|
||||
// security
|
||||
|
||||
fun distrustSystemCertificates() = settings.getBooleanFlow(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
fun updateDistrustSystemCertificates(distrust: Boolean) {
|
||||
settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, distrust)
|
||||
}
|
||||
|
||||
fun resetCertificates() {
|
||||
CustomCertStore.getInstance(context).clearUserDecisions()
|
||||
}
|
||||
|
||||
|
||||
// user interface
|
||||
|
||||
fun theme() = settings.getIntFlow(Settings.PREFERRED_THEME)
|
||||
fun updateTheme(theme: Int) {
|
||||
settings.putInt(Settings.PREFERRED_THEME, theme)
|
||||
UiUtils.updateTheme(context)
|
||||
}
|
||||
|
||||
fun resetHints() {
|
||||
settings.remove(BatteryOptimizationsPageModel.HINT_BATTERY_OPTIMIZATIONS)
|
||||
settings.remove(BatteryOptimizationsPageModel.HINT_AUTOSTART_PERMISSION)
|
||||
settings.remove(TasksModel.HINT_OPENTASKS_NOT_INSTALLED)
|
||||
settings.remove(OpenSourcePage.Model.SETTING_NEXT_DONATION_POPUP)
|
||||
}
|
||||
|
||||
|
||||
// tasks
|
||||
|
||||
val pm: PackageManager = context.packageManager
|
||||
private val appInfoFlow = tasksAppManager.currentProviderFlow(viewModelScope).map { tasksProvider ->
|
||||
tasksProvider?.packageName?.let { pkgName ->
|
||||
pm.getApplicationInfo(pkgName, 0)
|
||||
}
|
||||
}
|
||||
val appName = appInfoFlow.map { it?.loadLabel(pm)?.toString() }
|
||||
val icon = appInfoFlow.map { it?.loadIcon(pm) }
|
||||
|
||||
|
||||
// push
|
||||
|
||||
val pushEndpoint = preference.unifiedPushEndpointFlow()
|
||||
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import at.bitfire.davdroid.ui.composable.SafeAndroidUriHandler
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
// If applicable, call Activity.enableEdgeToEdge to enable edge-to-edge layout on Android <15, too.
|
||||
// When we have moved everything into one Activity with Compose navigation, we can call it there instead.
|
||||
(view.context as? AppCompatActivity)?.enableEdgeToEdge(
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
lightScrim = M3ColorScheme.lightScheme.scrim.toArgb(),
|
||||
darkScrim = M3ColorScheme.darkScheme.scrim.toArgb()
|
||||
) { darkTheme }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply SafeAndroidUriHandler to the composition
|
||||
val uriHandler = SafeAndroidUriHandler(LocalContext.current)
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (!darkTheme)
|
||||
M3ColorScheme.lightScheme
|
||||
else
|
||||
M3ColorScheme.darkScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.LocaleList
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.log.LogFileHandler
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import com.google.common.io.ByteStreams
|
||||
import com.google.common.io.Files
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.Writer
|
||||
import java.util.TimeZone
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter as asCalendarSyncAdapter
|
||||
import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter
|
||||
|
||||
@HiltViewModel(assistedFactory = DebugInfoModel.Factory::class)
|
||||
class DebugInfoModel @AssistedInject constructor(
|
||||
@Assisted private val details: DebugInfoDetails,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val settings: SettingsManager
|
||||
) : ViewModel() {
|
||||
|
||||
data class DebugInfoDetails(
|
||||
val account: Account?,
|
||||
val authority: String?,
|
||||
val cause: Throwable?,
|
||||
val localResource: String?,
|
||||
val remoteResource: String?,
|
||||
val logs: String?
|
||||
)
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun createWithDetails(details: DebugInfoDetails): DebugInfoModel
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_DEBUG_INFO = "debug-info.txt"
|
||||
private const val FILE_LOGS = "logs.txt"
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val cause: Throwable? = null,
|
||||
val localResource: String? = null,
|
||||
val remoteResource: String? = null,
|
||||
val logFile: File? = null,
|
||||
val debugInfo: File? = null,
|
||||
val zipFile: File? = null,
|
||||
val zipInProgress: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
var uiState by mutableStateOf(UiState())
|
||||
private set
|
||||
|
||||
fun resetError() {
|
||||
uiState = uiState.copy(error = null)
|
||||
}
|
||||
|
||||
fun resetZipFile() {
|
||||
uiState = uiState.copy(zipFile = null)
|
||||
}
|
||||
|
||||
init {
|
||||
// create debug info directory
|
||||
val debugDir = LogFileHandler.debugDir(context) ?: throw IOException("Couldn't create debug info directory")
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
// create log file from EXTRA_LOGS or log file
|
||||
if (details.logs != null) {
|
||||
val file = File(debugDir, FILE_LOGS)
|
||||
if (!file.exists() || file.canWrite()) {
|
||||
file.printWriter().use { writer ->
|
||||
writer.write(details.logs)
|
||||
}
|
||||
uiState = uiState.copy(logFile = file)
|
||||
} else
|
||||
logger.warning("Can't write logs to $file")
|
||||
} else LogFileHandler.getDebugLogFile(context)?.let { debugLogFile ->
|
||||
if (debugLogFile.isFile && debugLogFile.canRead())
|
||||
uiState = uiState.copy(logFile = debugLogFile)
|
||||
}
|
||||
|
||||
uiState = uiState.copy(
|
||||
cause = details.cause,
|
||||
localResource = details.localResource,
|
||||
remoteResource = details.remoteResource
|
||||
)
|
||||
generateDebugInfo(
|
||||
syncAccount = details.account,
|
||||
syncAuthority = details.authority,
|
||||
cause = details.cause,
|
||||
localResource = details.localResource,
|
||||
remoteResource = details.remoteResource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates debug info and saves it to [FILE_DEBUG_INFO] in [LogFileHandler.debugDir]
|
||||
*
|
||||
* Note: Part of this method and all of it's helpers (listed below) should probably be extracted in the future
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun generateDebugInfo(syncAccount: Account?, syncAuthority: String?, cause: Throwable?, localResource: String?, remoteResource: String?) {
|
||||
val debugInfoFile = File(LogFileHandler.debugDir(context), FILE_DEBUG_INFO)
|
||||
debugInfoFile.printWriter().use { writer ->
|
||||
writer.println("--- BEGIN DEBUG INFO ---")
|
||||
writer.println()
|
||||
|
||||
// begin with most specific information
|
||||
if (syncAccount != null || syncAuthority != null) {
|
||||
writer.append("SYNCHRONIZATION INFO\n")
|
||||
if (syncAccount != null)
|
||||
writer.append("Account: $syncAccount\n")
|
||||
if (syncAuthority != null)
|
||||
writer.append("Authority: $syncAuthority\n")
|
||||
writer.append("\n")
|
||||
}
|
||||
|
||||
if (cause != null) {
|
||||
writer.println("EXCEPTION")
|
||||
cause.printStackTrace(writer)
|
||||
writer.println()
|
||||
}
|
||||
|
||||
// exception details
|
||||
if (cause is DavException) {
|
||||
cause.request?.let { request ->
|
||||
writer.append("HTTP REQUEST\n$request\n")
|
||||
cause.requestBody?.let { writer.append(it) }
|
||||
writer.append("\n\n")
|
||||
}
|
||||
cause.response?.let { response ->
|
||||
writer.append("HTTP RESPONSE\n$response\n")
|
||||
cause.responseBody?.let { writer.append(it) }
|
||||
writer.append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (localResource != null)
|
||||
writer.append("LOCAL RESOURCE\n$localResource\n\n")
|
||||
|
||||
if (remoteResource != null)
|
||||
writer.append("REMOTE RESOURCE\n$remoteResource\n\n")
|
||||
|
||||
// software info
|
||||
try {
|
||||
writer.append("SOFTWARE INFORMATION\n")
|
||||
val table = TextTable("Package", "Version", "Code", "Installer", "Notes")
|
||||
val pm = context.packageManager
|
||||
|
||||
val packageNames = mutableSetOf( // we always want info about these packages:
|
||||
BuildConfig.APPLICATION_ID, // DAVx5
|
||||
TaskProvider.ProviderName.JtxBoard.packageName, // jtx Board
|
||||
TaskProvider.ProviderName.OpenTasks.packageName, // OpenTasks
|
||||
TaskProvider.ProviderName.TasksOrg.packageName // tasks.org
|
||||
)
|
||||
// ... and info about contact and calendar provider
|
||||
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY))
|
||||
pm.resolveContentProvider(authority, 0)?.let { packageNames += it.packageName }
|
||||
// ... and info about contact, calendar, task-editing apps
|
||||
val dataUris = arrayOf(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority),
|
||||
TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.TasksOrg.authority)
|
||||
)
|
||||
for (uri in dataUris) {
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, /* some random ID */ 1))
|
||||
for (info in pm.queryIntentActivities(viewIntent, 0))
|
||||
packageNames += info.activityInfo.packageName
|
||||
}
|
||||
|
||||
for (packageName in packageNames)
|
||||
try {
|
||||
val info = pm.getPackageInfo(packageName, 0)
|
||||
val appInfo = info.applicationInfo
|
||||
val notes = mutableListOf<String>()
|
||||
if (appInfo?.enabled == false)
|
||||
notes += "disabled"
|
||||
if (appInfo?.flags?.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
notes += "<em>on external storage</em>"
|
||||
table.addLine(
|
||||
info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info),
|
||||
pm.getInstallerPackageName(info.packageName) ?: '—', notes.joinToString(", ")
|
||||
)
|
||||
} catch (ignored: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
writer.append(table.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
|
||||
// system info
|
||||
val locales: Any = LocaleList.getAdjustedDefault()
|
||||
writer.append(
|
||||
"\n\nSYSTEM INFORMATION\n\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
|
||||
"Locale(s): $locales\n" +
|
||||
"Time zone: ${TimeZone.getDefault().id}\n"
|
||||
)
|
||||
val filesPath = Environment.getDataDirectory()
|
||||
val statFs = StatFs(filesPath.path)
|
||||
writer.append("Internal memory ($filesPath): ")
|
||||
.append(Formatter.formatFileSize(context, statFs.availableBytes))
|
||||
.append(" free of ")
|
||||
.append(Formatter.formatFileSize(context, statFs.totalBytes))
|
||||
.append("\n\n")
|
||||
|
||||
// power saving
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
context.getSystemService<UsageStatsManager>()?.let { statsManager ->
|
||||
val bucket = statsManager.appStandbyBucket
|
||||
writer
|
||||
.append("App standby bucket: ")
|
||||
.append(
|
||||
when {
|
||||
bucket <= 5 -> "exempted (very good)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active (good)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working set (bad: job restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent (bad: job restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare (very bad: job and network restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> "restricted (very bad: job and network restrictions apply)"
|
||||
else -> "$bucket"
|
||||
}
|
||||
)
|
||||
writer.append('\n')
|
||||
}
|
||||
context.getSystemService<PowerManager>()?.let { powerManager ->
|
||||
writer.append("App exempted from power saving: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes (good)" else "no (bad)")
|
||||
.append('\n')
|
||||
.append("System in power-save mode: ")
|
||||
.append(if (powerManager.isPowerSaveMode) "yes (restrictions apply!)" else "no")
|
||||
.append('\n')
|
||||
}
|
||||
// system-wide sync
|
||||
writer.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n\n")
|
||||
|
||||
// connectivity
|
||||
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
|
||||
writer.append("\n\nCONNECTIVITY\n\n")
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network ->
|
||||
val properties = connectivityManager.getLinkProperties(network)
|
||||
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
|
||||
writer.append(if (network == activeNetwork) " ☒ " else " ☐ ")
|
||||
.append(properties?.interfaceName ?: "?")
|
||||
.append("\n - ")
|
||||
.append(capabilities.toString().replace('&', ' '))
|
||||
.append('\n')
|
||||
}
|
||||
if (properties != null) {
|
||||
writer.append(" - DNS: ")
|
||||
.append(properties.dnsServers.joinToString(", ") { it.hostAddress })
|
||||
if (Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive)
|
||||
writer.append(" (private mode)")
|
||||
writer.append('\n')
|
||||
}
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
writer.append("System default proxy: ${proxy.host}:${proxy.port}\n")
|
||||
}
|
||||
writer.append("Data saver: ").append(
|
||||
when (connectivityManager.restrictBackgroundStatus) {
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled"
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted"
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled"
|
||||
else -> connectivityManager.restrictBackgroundStatus.toString()
|
||||
}
|
||||
).append('\n')
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
writer.append("\n\nCONFIGURATION\n")
|
||||
// notifications
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
writer.append("\nNotifications")
|
||||
if (!nm.areNotificationsEnabled())
|
||||
writer.append(" (blocked!)")
|
||||
writer.append(":\n")
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val channelsWithoutGroup = nm.notificationChannels.toMutableSet()
|
||||
for (group in nm.notificationChannelGroups) {
|
||||
writer.append(" - ${group.id}")
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
writer.append(" isBlocked=${group.isBlocked}")
|
||||
writer.append('\n')
|
||||
for (channel in group.channels) {
|
||||
writer.append(" * ${channel.id}: importance=${channel.importance}\n")
|
||||
channelsWithoutGroup -= channel
|
||||
}
|
||||
}
|
||||
for (channel in channelsWithoutGroup)
|
||||
writer.append(" - ${channel.id}: importance=${channel.importance}\n")
|
||||
}
|
||||
writer.append('\n')
|
||||
// permissions
|
||||
writer.append("Permissions:\n")
|
||||
val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
|
||||
for (permission in ownPkgInfo.requestedPermissions.orEmpty()) {
|
||||
val shortPermission = permission.removePrefix("android.permission.")
|
||||
writer.append(" - $shortPermission: ")
|
||||
.append(
|
||||
if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
|
||||
"granted"
|
||||
else
|
||||
"denied"
|
||||
)
|
||||
.append('\n')
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
// accounts
|
||||
writer.append("\nACCOUNTS")
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountRepository.getAll())
|
||||
dumpAccount(account, writer)
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
|
||||
if (addressBookAccounts.isNotEmpty()) {
|
||||
writer.append("ADDRESS BOOK ACCOUNTS\n\n")
|
||||
for (account in addressBookAccounts)
|
||||
dumpAddressBookAccount(account, accountManager, writer)
|
||||
}
|
||||
|
||||
// database dump
|
||||
writer.append("\n\nDATABASE DUMP\n\n")
|
||||
db.dump(writer, arrayOf("webdav_document"))
|
||||
|
||||
// app settings
|
||||
writer.append("\nAPP SETTINGS\n\n")
|
||||
settings.dump(writer)
|
||||
|
||||
writer.append("--- END DEBUG INFO ---\n")
|
||||
writer.toString()
|
||||
}
|
||||
uiState = uiState.copy(debugInfo = debugInfoFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ZIP file containing both [FILE_DEBUG_INFO] and [FILE_LOGS].
|
||||
*
|
||||
* Note: Part of this method should probably be extracted to a more suitable location
|
||||
*/
|
||||
fun generateZip() {
|
||||
try {
|
||||
uiState = uiState.copy(zipInProgress = true)
|
||||
|
||||
val file = File(LogFileHandler.debugDir(context), "davx5-debug.zip")
|
||||
logger.fine("Writing debug info to ${file.absolutePath}")
|
||||
ZipOutputStream(file.outputStream().buffered()).use { zip ->
|
||||
zip.setLevel(9)
|
||||
uiState.debugInfo?.let { debugInfo ->
|
||||
zip.putNextEntry(ZipEntry("debug-info.txt"))
|
||||
Files.copy(debugInfo, zip)
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
val logs = uiState.logFile
|
||||
if (logs != null) {
|
||||
// verbose logs available
|
||||
zip.putNextEntry(ZipEntry(logs.name))
|
||||
Files.copy(logs, zip)
|
||||
zip.closeEntry()
|
||||
} else {
|
||||
// logcat (short logs)
|
||||
try {
|
||||
Runtime.getRuntime().exec("logcat -d").also { logcat ->
|
||||
zip.putNextEntry(ZipEntry("logcat.txt"))
|
||||
ByteStreams.copy(logcat.inputStream, zip)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't attach logcat", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success, show ZIP file
|
||||
uiState = uiState.copy(zipFile = file)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't generate debug info ZIP", e)
|
||||
uiState = uiState.copy(error = e.localizedMessage)
|
||||
} finally {
|
||||
uiState = uiState.copy(zipInProgress = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Appends relevant android account information the given writer.
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun dumpAccount(account: Account, writer: Writer) {
|
||||
writer.append("\n\n - Account: ${account.name}\n")
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
|
||||
writer.append(dumpAccount(account, accountSettings, AccountDumpInfo.caldavAccount(context, account)))
|
||||
try {
|
||||
val credentials = accountSettings.credentials()
|
||||
val authStr = mutableListOf<String>()
|
||||
if (credentials.username != null)
|
||||
authStr += "user name"
|
||||
if (credentials.password != null)
|
||||
authStr += "password"
|
||||
if (credentials.certificateAlias != null)
|
||||
authStr += "client certificate"
|
||||
credentials.authState?.let { authState ->
|
||||
authStr += "OAuth [${authState.authorizationServiceConfiguration?.authorizationEndpoint}]"
|
||||
}
|
||||
if (authStr.isNotEmpty())
|
||||
writer .append(" Authentication: ")
|
||||
.append(authStr.joinToString(", "))
|
||||
.append("\n")
|
||||
|
||||
writer.append(" WiFi only: ${accountSettings.getSyncWifiOnly()}")
|
||||
accountSettings.getSyncWifiOnlySSIDs()?.let { ssids ->
|
||||
writer.append(", SSIDs: ${ssids.joinToString(", ")}")
|
||||
}
|
||||
writer.append(
|
||||
"\n Contact group method: ${accountSettings.getGroupMethod()}\n" +
|
||||
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
|
||||
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
|
||||
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
|
||||
" Use event colors: ${accountSettings.getEventColors()}\n"
|
||||
)
|
||||
|
||||
writer.append("\nSync workers:\n")
|
||||
.append(dumpSyncWorkersInfo(account))
|
||||
.append("\n")
|
||||
} catch (e: InvalidAccountException) {
|
||||
writer.append("$e\n")
|
||||
}
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends relevant address book type android account information to the given writer.
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*/
|
||||
private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) {
|
||||
writer.append(" * Address book: ${account.name}\n")
|
||||
val table = dumpAccount(account, null, AccountDumpInfo.addressBookAccount(account))
|
||||
writer.append(TextTable.indent(table, 4))
|
||||
.append("Collection ID: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)}\n")
|
||||
.append(" URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
|
||||
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves specified information from an android account
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*
|
||||
* @return the requested information
|
||||
*/
|
||||
private fun dumpAccount(account: Account, accountSettings: AccountSettings?, infos: Iterable<AccountDumpInfo>): String {
|
||||
val table = TextTable("Authority", "isSyncable", "syncAutomatically", "Interval", "Entries")
|
||||
for (info in infos) {
|
||||
var nrEntries = "—"
|
||||
if (info.countUri != null)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(info.authority)?.use { client ->
|
||||
client.query(info.countUri, null, null, null, null)?.use { cursor ->
|
||||
nrEntries = "${cursor.count} ${info.countStr}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
nrEntries = e.toString()
|
||||
}
|
||||
table.addLine(
|
||||
info.authority,
|
||||
ContentResolver.getIsSyncable(account, info.authority),
|
||||
ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync
|
||||
accountSettings?.getSyncInterval(info.authority)?.takeIf { it >= 0 }?.let {"${it/60} min"},
|
||||
nrEntries
|
||||
)
|
||||
}
|
||||
return table.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sync workers info
|
||||
* Note: WorkManager does not return worker names when queried, so we create them and ask
|
||||
* whether they exist one by one
|
||||
*/
|
||||
private fun dumpSyncWorkersInfo(account: Account): String {
|
||||
val table = TextTable("Tags", "Authority", "State", "Next run", "Retries", "Generation", "Periodicity")
|
||||
listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
).forEach { authority ->
|
||||
val tag = BaseSyncWorker.commonTag(account, authority)
|
||||
WorkManager.getInstance(context).getWorkInfos(
|
||||
WorkQuery.Builder.fromTags(listOf(tag)).build()
|
||||
).get().forEach { workInfo ->
|
||||
table.addLine(
|
||||
workInfo.tags.map { it.replace("\\bat\\.bitfire\\.davdroid\\.".toRegex(), ".") },
|
||||
authority,
|
||||
"${workInfo.state} (${workInfo.stopReason})",
|
||||
workInfo.nextScheduleTimeMillis.let { nextRun ->
|
||||
when (nextRun) {
|
||||
Long.MAX_VALUE -> "—"
|
||||
else -> DateUtils.getRelativeTimeSpanString(nextRun)
|
||||
}
|
||||
},
|
||||
workInfo.runAttemptCount,
|
||||
workInfo.generation,
|
||||
workInfo.periodicityInfo?.let { periodicity ->
|
||||
"every ${periodicity.repeatIntervalMillis/60000} min"
|
||||
} ?: "not periodic"
|
||||
)
|
||||
}
|
||||
}
|
||||
return table.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class AccountDumpInfo(
|
||||
val account: Account,
|
||||
val authority: String,
|
||||
val countUri: Uri?,
|
||||
val countStr: String?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun caldavAccount(context: Context, account: Account) = listOf(
|
||||
AccountDumpInfo(account, context.getString(R.string.address_books_authority), null, null),
|
||||
AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri(
|
||||
TaskProvider.ProviderName.OpenTasks.authority).asCalendarSyncAdapter(account), "OpenTasks task(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri(
|
||||
TaskProvider.ProviderName.TasksOrg.authority).asCalendarSyncAdapter(account), "tasks.org task(s)"),
|
||||
AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "wrongly assigned raw contact(s)")
|
||||
)
|
||||
|
||||
fun addressBookAccount(account: Account) = listOf(
|
||||
AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "raw contact(s)")
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.work.WorkInfo
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountProgressUseCase @Inject constructor(
|
||||
@ApplicationContext val context: Context
|
||||
) {
|
||||
|
||||
operator fun invoke(
|
||||
account: Account,
|
||||
serviceFlow: Flow<Service?>,
|
||||
authoritiesFlow: Flow<List<String>>
|
||||
): Flow<AccountProgress> {
|
||||
val serviceRefreshing = isServiceRefreshing(serviceFlow)
|
||||
val syncPending = isSyncPending(account, authoritiesFlow)
|
||||
val syncRunning = isSyncRunning(account, authoritiesFlow)
|
||||
|
||||
return combine(serviceRefreshing, syncPending, syncRunning) { refreshing, pending, syncing ->
|
||||
when {
|
||||
refreshing || syncing -> AccountProgress.Active
|
||||
pending -> AccountProgress.Pending
|
||||
else -> AccountProgress.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun isServiceRefreshing(serviceFlow: Flow<Service?>): Flow<Boolean> =
|
||||
serviceFlow.flatMapLatest { service ->
|
||||
if (service == null)
|
||||
flowOf(false)
|
||||
else
|
||||
RefreshCollectionsWorker.existsFlow(context, RefreshCollectionsWorker.workerName(service.id))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun isSyncPending(account: Account, authoritiesFlow: Flow<List<String>>): Flow<Boolean> =
|
||||
authoritiesFlow.flatMapLatest { authorities ->
|
||||
BaseSyncWorker.exists(
|
||||
context = context,
|
||||
workStates = listOf(WorkInfo.State.ENQUEUED),
|
||||
account = account,
|
||||
authorities = authorities,
|
||||
whichTag = { _, authority ->
|
||||
// we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker
|
||||
OneTimeSyncWorker.workerName(account, authority)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun isSyncRunning(account: Account, authoritiesFlow: Flow<List<String>>): Flow<Boolean> =
|
||||
authoritiesFlow.flatMapLatest { authorities ->
|
||||
BaseSyncWorker.exists(
|
||||
context = context,
|
||||
workStates = listOf(WorkInfo.State.RUNNING),
|
||||
account = account,
|
||||
authorities = authorities
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.account
|
||||
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.map
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flattenConcat
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Gets a list of collections for a service and type, optionally filtered by "show only personal" setting.
|
||||
*
|
||||
* Takes the "force read-only address books" setting into account: if set, all address books will have "forceReadOnly" set.
|
||||
*/
|
||||
class GetServiceCollectionPagerUseCase @Inject constructor(
|
||||
val collectionRepository: DavCollectionRepository,
|
||||
val settings: SettingsManager
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val PAGER_SIZE = 20
|
||||
}
|
||||
|
||||
val forceReadOnlyAddressBooksFlow = settings.getBooleanFlow(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false)
|
||||
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
operator fun invoke(
|
||||
serviceFlow: Flow<Service?>,
|
||||
collectionType: String,
|
||||
showOnlyPersonalFlow: Flow<AccountSettings.ShowOnlyPersonal?>
|
||||
): Flow<PagingData<Collection>> =
|
||||
combine(serviceFlow, showOnlyPersonalFlow, forceReadOnlyAddressBooksFlow) { service, onlyPersonal, forceReadOnlyAddressBooks ->
|
||||
if (service == null)
|
||||
flowOf(PagingData.empty<Collection>())
|
||||
else {
|
||||
val dataFlow = Pager(
|
||||
config = PagingConfig(PAGER_SIZE),
|
||||
pagingSourceFactory = {
|
||||
if (onlyPersonal?.onlyPersonal == true)
|
||||
collectionRepository.pagePersonalByServiceAndType(service.id, collectionType)
|
||||
else
|
||||
collectionRepository.pageByServiceAndType(service.id, collectionType)
|
||||
}
|
||||
).flow
|
||||
|
||||
// generate resulting flow; set "forceReadOnly" for every address book if requested
|
||||
if (forceReadOnlyAddressBooks)
|
||||
dataFlow.map { pagingData ->
|
||||
pagingData.map { collection ->
|
||||
if (collectionType == Collection.TYPE_ADDRESSBOOK)
|
||||
collection.copy(forceReadOnly = true)
|
||||
else
|
||||
collection
|
||||
}
|
||||
}
|
||||
else
|
||||
dataFlow
|
||||
}
|
||||
}.flattenConcat()
|
||||
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.composable
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import at.bitfire.davdroid.R
|
||||
|
||||
@Composable
|
||||
fun PasswordTextField(
|
||||
password: String,
|
||||
labelText: String?,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
isError: Boolean = false
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = labelText?.let { { Text(it) } },
|
||||
leadingIcon = leadingIcon,
|
||||
isError = isError,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
modifier = modifier.focusGroup(),
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
enabled = enabled,
|
||||
onClick = { passwordVisible = !passwordVisible }
|
||||
) {
|
||||
if (passwordVisible)
|
||||
Icon(Icons.Default.VisibilityOff, stringResource(R.string.login_password_hide))
|
||||
else
|
||||
Icon(Icons.Default.Visibility, stringResource(R.string.login_password_show))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PasswordTextField_Sample() {
|
||||
PasswordTextField(
|
||||
password = "",
|
||||
labelText = "labelText",
|
||||
enabled = true,
|
||||
isError = false,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PasswordTextField_Sample_Filled() {
|
||||
PasswordTextField(
|
||||
password = "password",
|
||||
labelText = "labelText",
|
||||
enabled = true,
|
||||
isError = false,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PasswordTextField_Sample_Error() {
|
||||
PasswordTextField(
|
||||
password = "password",
|
||||
labelText = "labelText",
|
||||
enabled = true,
|
||||
isError = true,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PasswordTextField_Sample_Disabled() {
|
||||
PasswordTextField(
|
||||
password = "password",
|
||||
labelText = "labelText",
|
||||
enabled = false,
|
||||
isError = false,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class SyncButtonWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = SyncButtonWidget()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user