mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-06 05:01:50 -05:00
Compare commits
401 Commits
v4.4.6-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 |
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>
|
||||
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,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,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,103 +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 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.copy(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.copy(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.copy(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,144 +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.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.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.assertTrue
|
||||
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 {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBook = LocalTestAddressBook.create(context, account, provider)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
addressBook.remove()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// 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"
|
||||
assertTrue(addressBook.renameAccount(newName))
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// 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().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@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
|
||||
// Needs 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.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 {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,285 +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.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.After
|
||||
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 {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
// clear contacts
|
||||
addressBookGroupsAsCategories.clear()
|
||||
addressBookGroupsAsVCards.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
addressBookGroupsAsCategories.remove()
|
||||
addressBookGroupsAsVCards.remove()
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
}
|
||||
|
||||
|
||||
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().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,147 +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.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted("addressBook") addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository,
|
||||
syncFramework: SyncFrameworkIntegration
|
||||
): LocalAddressBook(
|
||||
account = account,
|
||||
_addressBookAccount = addressBookAccount,
|
||||
provider = provider,
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
context = context,
|
||||
dirtyVerifier = Optional.empty(),
|
||||
logger = logger,
|
||||
serviceRepository = serviceRepository,
|
||||
syncFramework = syncFramework
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, @Assisted("addressBook") addressBookAccount: Account, 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()
|
||||
}
|
||||
|
||||
fun remove() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
assertTrue(accountManager.removeAccountExplicitly(addressBookAccount))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@dagger.hilt.EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface EntryPoint {
|
||||
fun localTestAddressBookFactory(): Factory
|
||||
}
|
||||
|
||||
val counter = AtomicInteger()
|
||||
|
||||
/**
|
||||
* Creates a [at.bitfire.davdroid.resource.LocalTestAddressBook].
|
||||
*
|
||||
* Make sure to delete it with [at.bitfire.davdroid.resource.LocalTestAddressBook.remove] or [removeAll] after use.
|
||||
*/
|
||||
fun create(context: Context, account: Account, provider: ContentProviderClient, groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS): LocalTestAddressBook {
|
||||
// create new address book account
|
||||
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", context.getString(R.string.account_type_address_book))
|
||||
val accountManager = AccountManager.get(context)
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
// return address book with this account
|
||||
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(context)
|
||||
val factory = entryPoint.localTestAddressBookFactory()
|
||||
return factory.create(account, addressBookAccount, provider, groupMethod)
|
||||
}
|
||||
|
||||
fun removeAll(context: Context) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,754 +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 {
|
||||
|
||||
@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
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
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 set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
// 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_PERSONAL"))
|
||||
)
|
||||
|
||||
// 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_PERSONAL"))
|
||||
)
|
||||
|
||||
// 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_PERSONAL"))
|
||||
)
|
||||
|
||||
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_PERSONAL"))
|
||||
)
|
||||
|
||||
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_PERSONAL"))
|
||||
)
|
||||
|
||||
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_PERSONAL"))
|
||||
)
|
||||
|
||||
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_PERSONAL"))
|
||||
)
|
||||
|
||||
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_PERSONAL"))
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
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"
|
||||
}
|
||||
|
||||
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_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
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_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<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_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>Freds Contacts (not mine)</displayname>" +
|
||||
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
|
||||
"</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_PERSONAL ->
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
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 +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,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,46 +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 at.bitfire.dav4jvm.property.push.Topic
|
||||
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)
|
||||
val properties = pushMessage.propStat?.properties ?: return@processTag
|
||||
val pushTopic = properties.filterIsInstance<Topic>().firstOrNull()
|
||||
topic = pushTopic?.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,117 +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.db.Collection.Companion.TYPE_ADDRESSBOOK
|
||||
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.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Lazy
|
||||
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 tasksAppManager: Lazy<TasksAppManager>
|
||||
|
||||
@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 syncDataTypes = mutableSetOf<SyncDataType>()
|
||||
// If the type is an address book, add the contacts type
|
||||
if (collection.type == TYPE_ADDRESSBOOK)
|
||||
syncDataTypes += SyncDataType.CONTACTS
|
||||
|
||||
// If the collection supports events, add the events type
|
||||
if (collection.supportsVEVENT != false)
|
||||
syncDataTypes += SyncDataType.EVENTS
|
||||
|
||||
// If the collection supports tasks, make sure there's a provider installed,
|
||||
// and add the tasks type
|
||||
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
|
||||
if (tasksAppManager.get().currentProvider() != null)
|
||||
syncDataTypes += SyncDataType.TASKS
|
||||
|
||||
// Schedule sync for all the types identified
|
||||
val account = accountRepository.fromName(service.accountName)
|
||||
for (syncDataType in syncDataTypes)
|
||||
syncWorkerManager.enqueueOneTime(account, syncDataType, 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,247 +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 androidx.core.content.contentValuesOf
|
||||
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 = contentValuesOf(COLUMN_SYNC_STATE to 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 = contentValuesOf(LocalEvent.COLUMN_FLAGS to 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 = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
|
||||
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,267 +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 androidx.core.content.contentValuesOf
|
||||
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),
|
||||
contentValuesOf(Events.DELETED to 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 = contentValuesOf(Events.UID_2445 to 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 = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Events.DELETED to 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,114 +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 androidx.core.content.contentValuesOf
|
||||
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 = contentValuesOf(Tasks._UID to 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 = contentValuesOf(COLUMN_FLAGS to 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,130 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
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.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
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> {
|
||||
|
||||
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 = contentValuesOf(TaskLists.SYNC_VERSION to 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 = contentValuesOf(LocalTask.COLUMN_FLAGS to 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 = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
|
||||
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,421 +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.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
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.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.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 private val service: Service,
|
||||
@Assisted private 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
private val collectionProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Starting at given 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
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += 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)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrl(
|
||||
// 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 while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += 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 (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, *collectionProperties) { 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 home set itself
|
||||
homeSetRepository.insertOrUpdateByUrl(localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
))
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
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)
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(
|
||||
homelessCollection.copy(homeSetId = null)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, *collectionProperties) { 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
|
||||
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: 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, *principalProperties) { 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,291 +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)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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,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.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 androidx.work.WorkInfo
|
||||
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.BaseSyncWorker
|
||||
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.
|
||||
*
|
||||
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
|
||||
*/
|
||||
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 (SyncFrameworkIntegration) 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
|
||||
}
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.fromAuthority(authority), 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 { infoList ->
|
||||
for (info in infoList)
|
||||
if (info.state.isFinished) {
|
||||
if (info.state == WorkInfo.State.FAILED) {
|
||||
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
|
||||
syncResult.tooManyRetries = true
|
||||
else
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
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.log(Level.INFO, "Returning to sync framework.", syncResult)
|
||||
}
|
||||
|
||||
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,57 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
enum class SyncDataType {
|
||||
|
||||
CONTACTS,
|
||||
EVENTS,
|
||||
TASKS;
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncDataTypeEntryPoint {
|
||||
fun tasksAppManager(): TasksAppManager
|
||||
}
|
||||
|
||||
|
||||
fun possibleAuthorities(): List<String> =
|
||||
when (this) {
|
||||
CONTACTS -> listOf(
|
||||
ContactsContract.AUTHORITY
|
||||
)
|
||||
EVENTS -> listOf(
|
||||
CalendarContract.AUTHORITY
|
||||
)
|
||||
TASKS ->
|
||||
TaskProvider.ProviderName.entries.map { it.authority }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromAuthority(authority: String): SyncDataType {
|
||||
return when (authority) {
|
||||
ContactsContract.AUTHORITY ->
|
||||
CONTACTS
|
||||
CalendarContract.AUTHORITY ->
|
||||
EVENTS
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority ->
|
||||
TASKS
|
||||
else -> throw IllegalArgumentException("Unknown authority: $authority")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,47 +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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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(
|
||||
@ApplicationContext context: Context
|
||||
) {
|
||||
|
||||
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,137 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Handles all Sync Adapter Framework related interaction. Other classes should never call
|
||||
* `ContentResolver.setIsSyncable()` or something similar themselves. Everything sync-framework
|
||||
* related must be handled by this class.
|
||||
*
|
||||
* Sync requests from the Sync Adapter Framework are handled by [SyncAdapterService].
|
||||
*/
|
||||
class SyncFrameworkIntegration @Inject constructor(
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Gets the global auto-sync setting that applies to all the providers and accounts. If this is
|
||||
* false then the per-provider auto-sync setting is ignored.
|
||||
*/
|
||||
fun getMasterSyncAutomatically() =
|
||||
ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
/**
|
||||
* Check if this account/provider is syncable.
|
||||
*/
|
||||
fun isSyncable(account: Account, authority: String): Boolean =
|
||||
ContentResolver.getIsSyncable(account, authority) > 0
|
||||
|
||||
/**
|
||||
* Enable this account/provider to be syncable.
|
||||
*/
|
||||
fun enableSyncAbility(account: Account, authority: String) {
|
||||
logger.fine("Enabling sync framework for account=$account, authority=$authority")
|
||||
if (ContentResolver.getIsSyncable(account, authority) != 1)
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable this account/provider to be syncable.
|
||||
*/
|
||||
fun disableSyncAbility(account: Account, authority: String) {
|
||||
logger.fine("Disabling sync framework for account=$account, authority=$authority")
|
||||
if (ContentResolver.getIsSyncable(account, authority) != 0)
|
||||
ContentResolver.setIsSyncable(account, authority, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider should be synced when content (contact, calendar event or task) changes.
|
||||
*/
|
||||
fun syncsOnContentChange(account: Account, authority: String) =
|
||||
ContentResolver.getSyncAutomatically(account, authority)
|
||||
|
||||
/**
|
||||
* Enable syncing on content (contact, calendar event or task) changes.
|
||||
*/
|
||||
fun enableSyncOnContentChange(account: Account, authority: String) {
|
||||
if (!isSyncable(account, authority))
|
||||
enableSyncAbility(account, authority)
|
||||
|
||||
if (!ContentResolver.getSyncAutomatically(account, authority))
|
||||
setSyncOnContentChange(account, authority, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable syncing on content (contact, calendar event or task) changes.
|
||||
*/
|
||||
fun disableSyncOnContentChange(account: Account, authority: String) {
|
||||
if (ContentResolver.getSyncAutomatically(account, authority))
|
||||
setSyncOnContentChange(account, authority, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Because there is no callback for when the sync status/interval has been updated, 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 account account to enable/disable content change sync triggers for
|
||||
* @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
|
||||
private fun setSyncOnContentChange(account: Account, authority: String, enable: Boolean): Boolean {
|
||||
logger.fine("Setting content-triggered syncs (sync framework) for account=$account, authority=$authority to enable=$enable")
|
||||
// Try up to 10 times with 100 ms pause
|
||||
repeat(10) {
|
||||
if (setContentTrigger(account, authority, enable)) {
|
||||
// Remove periodic syncs created by ContentResolver.setSyncAutomatically
|
||||
ContentResolver.getPeriodicSyncs(account, authority).forEach { periodicSync ->
|
||||
ContentResolver.removePeriodicSync(
|
||||
periodicSync.account,
|
||||
periodicSync.authority,
|
||||
periodicSync.extras
|
||||
)
|
||||
}
|
||||
// Set successfully
|
||||
return true
|
||||
}
|
||||
Thread.sleep(100)
|
||||
}
|
||||
// Failed to set
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable content change sync triggers of the Sync Adapter Framework.
|
||||
*
|
||||
* @param account account to enable/disable content change sync triggers for
|
||||
* @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
|
||||
*/
|
||||
private fun setContentTrigger(account: Account, authority: String, enable: Boolean): Boolean =
|
||||
if (enable) {
|
||||
ContentResolver.setSyncAutomatically(account, authority, true)
|
||||
/* return */ ContentResolver.getSyncAutomatically(account, authority)
|
||||
} else {
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.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)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
logger.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_task)
|
||||
|
||||
}
|
||||
@@ -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,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,72 +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.CollectionType
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
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.flatMapLatest
|
||||
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 collectionType: String,
|
||||
showOnlyPersonalFlow: Flow<Boolean>
|
||||
): Flow<PagingData<Collection>> =
|
||||
combine(serviceFlow, showOnlyPersonalFlow, forceReadOnlyAddressBooksFlow) { service, onlyPersonal, forceReadOnlyAddressBooks ->
|
||||
service?.let { service ->
|
||||
val dataFlow = Pager(
|
||||
config = PagingConfig(PAGER_SIZE),
|
||||
pagingSourceFactory = {
|
||||
if (onlyPersonal == true)
|
||||
collectionRepository.pagePersonalByServiceAndType(service.id, collectionType)
|
||||
else
|
||||
collectionRepository.pageByServiceAndType(service.id, collectionType)
|
||||
}
|
||||
).flow
|
||||
|
||||
// set "forceReadOnly" for every address book if requested
|
||||
if (forceReadOnlyAddressBooks && collectionType == Collection.TYPE_ADDRESSBOOK)
|
||||
dataFlow.map { pagingData ->
|
||||
pagingData.map { collection ->
|
||||
collection.copy(forceReadOnly = true)
|
||||
}
|
||||
}
|
||||
else
|
||||
dataFlow
|
||||
} ?: flowOf(PagingData.empty())
|
||||
}.flatMapLatest { it }
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.app.AuthenticationRequiredException
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Point
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsContract.buildChildDocumentsUri
|
||||
import android.provider.DocumentsContract.buildRootsUri
|
||||
import android.provider.DocumentsProvider
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentLength
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetLastModified
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.MemoryCookieStore
|
||||
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
|
||||
import at.bitfire.davdroid.webdav.DavDocumentsProvider.DavDocumentsActor
|
||||
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.EntryPoints
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Provides functionality on WebDav documents.
|
||||
*
|
||||
* Actual implementation should go into [DavDocumentsActor].
|
||||
*/
|
||||
class DavDocumentsProvider: DocumentsProvider() {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DavDocumentsProviderEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
fun logger(): Logger
|
||||
fun randomAccessCallbackWrapperFactory(): RandomAccessCallbackWrapper.Factory
|
||||
fun streamingFileDescriptorFactory(): StreamingFileDescriptor.Factory
|
||||
fun webdavComponentBuilder(): WebdavComponentBuilder
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(WebdavComponent::class)
|
||||
interface DavDocumentsProviderWebdavEntryPoint {
|
||||
fun credentialsStore(): CredentialsStore
|
||||
fun thumbnailCache(): ThumbnailCache
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DAV_FILE_FIELDS = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
GetETag.NAME,
|
||||
GetContentType.NAME,
|
||||
GetContentLength.NAME,
|
||||
GetLastModified.NAME,
|
||||
QuotaAvailableBytes.NAME,
|
||||
QuotaUsedBytes.NAME,
|
||||
)
|
||||
|
||||
const val MAX_NAME_ATTEMPTS = 5
|
||||
const val THUMBNAIL_TIMEOUT_MS = 15000L
|
||||
|
||||
fun notifyMountsChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(buildRootsUri(context.getString(R.string.webdav_authority)), null)
|
||||
}
|
||||
}
|
||||
|
||||
private val ourContext by lazy { context!! } // requireContext() requires API level 30
|
||||
private val authority by lazy { ourContext.getString(R.string.webdav_authority) }
|
||||
private val globalEntryPoint by lazy { EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(ourContext) }
|
||||
private val webdavEntryPoint by lazy {
|
||||
EntryPoints.get(
|
||||
globalEntryPoint.webdavComponentBuilder().build(),
|
||||
DavDocumentsProviderWebdavEntryPoint::class.java
|
||||
)
|
||||
}
|
||||
|
||||
private val logger by lazy { globalEntryPoint.logger() }
|
||||
|
||||
private val db by lazy { globalEntryPoint.appDatabase() }
|
||||
private val mountDao by lazy { db.webDavMountDao() }
|
||||
private val documentDao by lazy { db.webDavDocumentDao() }
|
||||
|
||||
private val credentialsStore by lazy { webdavEntryPoint.credentialsStore() }
|
||||
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
|
||||
private val thumbnailCache by lazy { webdavEntryPoint.thumbnailCache() }
|
||||
|
||||
private val connectivityManager by lazy { ourContext.getSystemService<ConnectivityManager>()!! }
|
||||
private val storageManager by lazy { ourContext.getSystemService<StorageManager>()!! }
|
||||
|
||||
/** List of currently active [queryChildDocuments] runners.
|
||||
*
|
||||
* Key: document ID (directory) for which children are listed.
|
||||
* Value: whether the runner is still running (*true*) or has already finished (*false*).
|
||||
*/
|
||||
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
private val actor by lazy { DavDocumentsActor(ourContext, db, logger, cookieStore, credentialsStore, authority) }
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
|
||||
/*** query ***/
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryRoots")
|
||||
val roots = MatrixCursor(projection ?: arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_ICON,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_SUMMARY
|
||||
))
|
||||
|
||||
for (mount in mountDao.getAll()) {
|
||||
val rootDocument = documentDao.getOrCreateRoot(mount)
|
||||
logger.info("Root ID: $rootDocument")
|
||||
|
||||
roots.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, mount.id)
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(Root.COLUMN_TITLE, ourContext.getString(R.string.webdav_provider_root_title))
|
||||
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
|
||||
add(Root.COLUMN_SUMMARY, mount.name)
|
||||
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
|
||||
|
||||
val quotaAvailable = rootDocument.quotaAvailable
|
||||
if (quotaAvailable != null)
|
||||
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
|
||||
|
||||
val quotaUsed = rootDocument.quotaUsed
|
||||
if (quotaAvailable != null && quotaUsed != null)
|
||||
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val parent = doc.parentId?.let { parentId ->
|
||||
documentDao.get(parentId)
|
||||
}
|
||||
|
||||
return DocumentsCursor(projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_ICON,
|
||||
Document.COLUMN_SUMMARY
|
||||
)).apply {
|
||||
val bundle = doc.toBundle(parent)
|
||||
logger.fine("queryDocument($documentId) = $bundle")
|
||||
|
||||
// override display names of root documents
|
||||
if (parent == null) {
|
||||
val mount = mountDao.getById(doc.mountId)
|
||||
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
|
||||
}
|
||||
|
||||
addRow(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets old or new children of given parent.
|
||||
*
|
||||
* Dispatches a worker querying the server for new children of given parent, and instantly
|
||||
* returns old children (or nothing, on initial call).
|
||||
* Once the worker finishes its query, it notifies the [android.content.ContentResolver] about
|
||||
* change, which calls this method again. The worker being done
|
||||
*/
|
||||
@Synchronized
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
|
||||
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
|
||||
val parentId = parentDocumentId.toLong()
|
||||
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
|
||||
|
||||
val columns = projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
// Register watcher
|
||||
val result = DocumentsCursor(columns)
|
||||
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
|
||||
result.setNotificationUri(ourContext.contentResolver, notificationUri)
|
||||
|
||||
// Dispatch worker querying for the children and keep track of it
|
||||
val running = runningQueryChildren.getOrPut(parentId) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
actor.queryChildren(parent)
|
||||
// Once the query is done, set query as finished (not running)
|
||||
runningQueryChildren[parentId] = false
|
||||
// .. and notify - effectively calling this method again
|
||||
ourContext.contentResolver.notifyChange(notificationUri, null)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
if (running) // worker still running
|
||||
result.loading = true
|
||||
else // remove worker from list if done
|
||||
runningQueryChildren.remove(parentId)
|
||||
|
||||
// Regardless of whether the worker is done, return the children we already have
|
||||
for (child in documentDao.getChildren(parentId)) {
|
||||
val bundle = child.toBundle(parent)
|
||||
result.addRow(bundle)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
|
||||
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
while (iter != null) {
|
||||
val currentParentId = iter.parentId
|
||||
if (currentParentId == parent.id)
|
||||
return true
|
||||
|
||||
iter = if (currentParentId != null)
|
||||
documentDao.get(currentParentId)
|
||||
else
|
||||
null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/*** copy/create/delete/move/rename ***/
|
||||
|
||||
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String {
|
||||
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
|
||||
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val name = srcDoc.name
|
||||
|
||||
if (srcDoc.mountId != dstFolder.mountId)
|
||||
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
|
||||
|
||||
val dstDocId: String
|
||||
actor.httpClient(srcDoc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
|
||||
try {
|
||||
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(name)
|
||||
.build()
|
||||
dav.copy(dstUrl, false) {
|
||||
// successfully copied
|
||||
}
|
||||
|
||||
dstDocId = documentDao.insertOrReplace(WebDavDocument(
|
||||
mountId = dstFolder.mountId,
|
||||
parentId = dstFolder.id,
|
||||
name = name,
|
||||
isDirectory = srcDoc.isDirectory,
|
||||
displayName = srcDoc.displayName,
|
||||
mimeType = srcDoc.mimeType,
|
||||
size = srcDoc.size
|
||||
)).toString()
|
||||
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
if (e.code == HttpURLConnection.HTTP_NOT_FOUND)
|
||||
throw FileNotFoundException()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return dstDocId
|
||||
}
|
||||
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? {
|
||||
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
val createDirectory = mimeType == Document.MIME_TYPE_DIR
|
||||
|
||||
var docId: Long? = null
|
||||
actor.httpClient(parent.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val newLocation = parentUrl.newBuilder()
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
val doc = DavResource(client.okHttpClient, newLocation)
|
||||
try {
|
||||
if (createDirectory)
|
||||
doc.mkCol(null) {
|
||||
// directory successfully created
|
||||
}
|
||||
else
|
||||
doc.put("".toRequestBody(null), ifNoneMatch = true) {
|
||||
// document successfully created
|
||||
}
|
||||
|
||||
docId = documentDao.insertOrReplace(WebDavDocument(
|
||||
mountId = parent.mountId,
|
||||
parentId = parent.id,
|
||||
name = newName,
|
||||
mimeType = mimeType.toMediaTypeOrNull(),
|
||||
isDirectory = createDirectory
|
||||
))
|
||||
|
||||
actor.notifyFolderChanged(parentDocumentId)
|
||||
break
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docId?.toString()
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) {
|
||||
logger.fine("WebDAV removeDocument $documentId")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
dav.delete {
|
||||
// successfully deleted
|
||||
}
|
||||
logger.fine("Successfully removed")
|
||||
documentDao.delete(doc)
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String {
|
||||
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
|
||||
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
if (doc.mountId != dstParent.mountId)
|
||||
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
|
||||
|
||||
val newLocation = dstParent.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(doc.name)
|
||||
.build()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully moved
|
||||
}
|
||||
|
||||
documentDao.update(doc.copy(parentId = dstParent.id))
|
||||
|
||||
actor.notifyFolderChanged(sourceParentDocumentId)
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
|
||||
return doc.id.toString()
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String? {
|
||||
logger.fine("WebDAV renameDocument $documentId $displayName")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val oldUrl = doc.toHttpUrl(db)
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val newLocation = oldUrl.newBuilder()
|
||||
.removePathSegment(oldUrl.pathSegments.lastIndex)
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
try {
|
||||
val dav = DavResource(client.okHttpClient, oldUrl)
|
||||
dav.move(newLocation, false) {
|
||||
// successfully renamed
|
||||
}
|
||||
documentDao.update(doc.copy(name = newName))
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
return doc.id.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
|
||||
val safeName = displayName.filterNot { it.isISOControl() }
|
||||
|
||||
if (appendNumber != 0) {
|
||||
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
|
||||
if (extension != null) {
|
||||
val baseName = safeName.removeSuffix(".$extension")
|
||||
return "${baseName}_$appendNumber.$extension"
|
||||
} else
|
||||
return "${safeName}_$appendNumber"
|
||||
} else
|
||||
return safeName
|
||||
}
|
||||
|
||||
|
||||
/*** read/write ***/
|
||||
|
||||
private fun headRequest(client: HttpClient, url: HttpUrl, signal: CancellationSignal?): HeadResponse {
|
||||
val response = CoroutineScope(Dispatchers.IO).async {
|
||||
runInterruptible {
|
||||
HeadResponse.fromUrl(client, url)
|
||||
}
|
||||
}
|
||||
signal?.setOnCancelListener {
|
||||
response.cancel("Cancelled by signal")
|
||||
}
|
||||
return runBlocking {
|
||||
response.await()
|
||||
}
|
||||
}
|
||||
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor {
|
||||
logger.fine("WebDAV openDocument $documentId $mode $signal")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val url = doc.toHttpUrl(db)
|
||||
val client = actor.httpClient(doc.mountId, logBody = false)
|
||||
|
||||
val modeFlags = ParcelFileDescriptor.parseMode(mode)
|
||||
val readAccess = when (mode) {
|
||||
"r" -> true
|
||||
"w", "wt" -> false
|
||||
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
|
||||
}
|
||||
|
||||
val fileInfo = headRequest(client, url, signal)
|
||||
logger.fine("Received file info: $fileInfo")
|
||||
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return if (
|
||||
Build.VERSION.SDK_INT >= 26 && // openProxyFileDescriptor exists since Android 8.0
|
||||
readAccess && // WebDAV doesn't support random write access natively
|
||||
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
|
||||
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
|
||||
fileInfo.supportsPartial == true // WebDAV server must support random access
|
||||
) {
|
||||
logger.fine("Creating RandomAccessCallback for $url")
|
||||
val factory = globalEntryPoint.randomAccessCallbackWrapperFactory()
|
||||
val accessor = factory.create(client, url, doc.mimeType, fileInfo, signal)
|
||||
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
|
||||
} else {
|
||||
logger.fine("Creating StreamingFileDescriptor for $url")
|
||||
val factory = globalEntryPoint.streamingFileDescriptorFactory()
|
||||
val fd = factory.create(client, url, doc.mimeType, signal) { transferred ->
|
||||
// called when transfer is finished
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!readAccess /* write access */) {
|
||||
// write access, update file size
|
||||
documentDao.update(doc.copy(size = transferred, lastModified = now))
|
||||
}
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
}
|
||||
|
||||
if (readAccess)
|
||||
fd.download()
|
||||
else
|
||||
fd.upload()
|
||||
}
|
||||
}
|
||||
|
||||
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
|
||||
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
|
||||
|
||||
if (connectivityManager.isActiveNetworkMetered)
|
||||
// don't download the large images just to create a thumbnail on metered networks
|
||||
return null
|
||||
|
||||
if (signal == null) {
|
||||
// see https://github.com/zhanghai/MaterialFiles/issues/588
|
||||
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
|
||||
return null
|
||||
}
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
val docCacheKey = doc.cacheKey()
|
||||
if (docCacheKey == null) {
|
||||
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
|
||||
// create thumbnail
|
||||
val job = CoroutineScope(Dispatchers.IO).async {
|
||||
withTimeout(THUMBNAIL_TIMEOUT_MS) {
|
||||
actor.httpClient(doc.mountId, logBody = false).use { client ->
|
||||
val url = doc.toHttpUrl(db)
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var result: ByteArray? = null
|
||||
runInterruptible {
|
||||
dav.get("image/*", null) { response ->
|
||||
response.body?.byteStream()?.use { data ->
|
||||
BitmapFactory.decodeStream(data)?.let { bitmap ->
|
||||
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
|
||||
val baos = ByteArrayOutputStream()
|
||||
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
|
||||
result = baos.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signal.setOnCancelListener {
|
||||
logger.fine("Cancelling thumbnail for ${doc.name}")
|
||||
job.cancel("Cancelled by signal")
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
job.await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbFile != null)
|
||||
return AssetFileDescriptor(
|
||||
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
|
||||
0, thumbFile.length()
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Acts on behalf of [DavDocumentsProvider].
|
||||
*
|
||||
* Encapsulates functionality to make it easily testable without generating lots of
|
||||
* DocumentProviders during the tests.
|
||||
*
|
||||
* By containing the actual implementation logic of [DavDocumentsProvider], it adds a layer of separation
|
||||
* to make the methods of [DavDocumentsProvider] more easily testable.
|
||||
* [DavDocumentsProvider]s methods should do nothing more, but to call [DavDocumentsActor]s methods.
|
||||
*/
|
||||
class DavDocumentsActor(
|
||||
private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val cookieStore: MutableMap<Long, CookieJar>,
|
||||
private val credentialsStore: CredentialsStore,
|
||||
private val authority: String
|
||||
) {
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
/**
|
||||
* Finds children of given parent [WebDavDocument]. After querying, it
|
||||
* updates existing children, adds new ones or removes deleted ones.
|
||||
*
|
||||
* There must never be more than one running instance per [parent]!
|
||||
*
|
||||
* @param parent folder to search for children
|
||||
*/
|
||||
@WorkerThread
|
||||
internal fun queryChildren(parent: WebDavDocument) {
|
||||
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
|
||||
val newChildrenList = hashMapOf<String, WebDavDocument>()
|
||||
|
||||
httpClient(parent.mountId).use { client ->
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
val folder = DavCollection(client.okHttpClient, parentUrl)
|
||||
|
||||
try {
|
||||
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
|
||||
logger.fine("$relation $response")
|
||||
|
||||
val resource: WebDavDocument =
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF -> // it's about the parent
|
||||
parent
|
||||
Response.HrefRelation.MEMBER -> // it's about a member
|
||||
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
|
||||
else -> {
|
||||
// we didn't request this; log a warning and ignore it
|
||||
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
|
||||
return@propfind
|
||||
}
|
||||
}
|
||||
|
||||
val updatedResource = resource.copy(
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) ?: resource.isDirectory,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
mimeType = response[GetContentType::class.java]?.type,
|
||||
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
|
||||
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
|
||||
size = response[GetContentLength::class.java]?.contentLength,
|
||||
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
|
||||
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
|
||||
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
|
||||
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
|
||||
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
|
||||
)
|
||||
|
||||
if (resource == parent)
|
||||
documentDao.update(updatedResource)
|
||||
else {
|
||||
documentDao.insertOrUpdate(updatedResource)
|
||||
newChildrenList[resource.name] = updatedResource
|
||||
}
|
||||
|
||||
// remove resource from known child nodes, because not found on server
|
||||
oldChildren.remove(resource.name)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't query children", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete child nodes which were not rediscovered (deleted serverside)
|
||||
for ((_, oldChild) in oldChildren)
|
||||
documentDao.delete(oldChild)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Creates a HTTP client that can be used to access resources in the given mount.
|
||||
*
|
||||
* @param mountId ID of the mount to access
|
||||
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
|
||||
*/
|
||||
internal fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient {
|
||||
val builder = HttpClient.Builder(
|
||||
context = context,
|
||||
loggerLevel = if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS
|
||||
).cookieStore(
|
||||
cookieStore.getOrPut(mountId) { MemoryCookieStore() }
|
||||
)
|
||||
|
||||
credentialsStore.getCredentials(mountId)?.let { credentials ->
|
||||
builder.addAuthentication(null, credentials, true)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(parentDocumentId: Long?) {
|
||||
if (parentDocumentId != null)
|
||||
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null)
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(parentDocumentId: String) {
|
||||
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun HttpException.throwForDocumentProvider(ignorePreconditionFailed: Boolean = false) {
|
||||
when (code) {
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED -> {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
// TODO edit mount
|
||||
val intent = Intent(ourContext, WebdavMountsActivity::class.java)
|
||||
throw AuthenticationRequiredException(this, PendingIntent.getActivity(ourContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
}
|
||||
}
|
||||
HttpURLConnection.HTTP_NOT_FOUND ->
|
||||
throw FileNotFoundException()
|
||||
HttpURLConnection.HTTP_PRECON_FAILED ->
|
||||
if (ignorePreconditionFailed)
|
||||
return
|
||||
}
|
||||
|
||||
// re-throw
|
||||
throw this
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ProxyFileDescriptorCallback
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import android.text.format.Formatter
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.CacheLoader
|
||||
import com.google.common.cache.LoadingCache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@TargetApi(26)
|
||||
class RandomAccessCallback @AssistedInject constructor(
|
||||
@Assisted val httpClient: HttpClient,
|
||||
@Assisted val url: HttpUrl,
|
||||
@Assisted val mimeType: MediaType?,
|
||||
@Assisted headResponse: HeadResponse,
|
||||
@Assisted private val cancellationSignal: CancellationSignal?,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
): ProxyFileDescriptorCallback() {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* WebDAV resources will be read in chunks of this size (or less at the end of the file).
|
||||
*/
|
||||
const val MAX_PAGE_SIZE = 2 * 1024*1024 // 2 MB
|
||||
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, cancellationSignal: CancellationSignal?): RandomAccessCallback
|
||||
}
|
||||
|
||||
data class PageIdentifier(
|
||||
val offset: Long,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
private val dav = DavResource(httpClient.okHttpClient, url)
|
||||
|
||||
private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size")
|
||||
private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified")
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentTitle(context.getString(R.string.webdav_notification_access))
|
||||
.setContentText(dav.fileName())
|
||||
.setSubText(Formatter.formatFileSize(context, fileSize))
|
||||
.setSmallIcon(R.drawable.ic_storage_notify)
|
||||
.setOngoing(true)
|
||||
private val notificationTag = url.toString()
|
||||
|
||||
private val pageLoader = PageLoader()
|
||||
private val pageCache: LoadingCache<PageIdentifier, ByteArray> = CacheBuilder.newBuilder()
|
||||
.maximumSize(10) // don't cache more than 10 entries (MAX_PAGE_SIZE each)
|
||||
.softValues() // use SoftReference for the page contents so they will be garbage collected if memory is needed
|
||||
.build(pageLoader) // fetch actual content using pageLoader
|
||||
|
||||
private val pagingReader = PagingReader(fileSize, MAX_PAGE_SIZE, pageCache)
|
||||
|
||||
init {
|
||||
cancellationSignal?.let {
|
||||
logger.fine("Cancelling random access to $url")
|
||||
pageLoader.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onFsync() { /* not used */ }
|
||||
|
||||
override fun onGetSize(): Long {
|
||||
logger.fine("onGetFileSize $url")
|
||||
throwIfCancelled("onGetFileSize")
|
||||
return fileSize
|
||||
}
|
||||
|
||||
override fun onRead(offset: Long, size: Int, data: ByteArray): Int {
|
||||
logger.fine("onRead $url $offset $size")
|
||||
throwIfCancelled("onRead")
|
||||
|
||||
try {
|
||||
return pagingReader.read(offset, size, data)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't read from WebDAV resource", e)
|
||||
throw e.toErrNoException("onRead")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWrite(offset: Long, size: Int, data: ByteArray): Int {
|
||||
logger.fine("onWrite $url $offset $size")
|
||||
// ranged write requests not supported by WebDAV (yet)
|
||||
throw ErrnoException("onWrite", OsConstants.EROFS)
|
||||
}
|
||||
|
||||
override fun onRelease() {
|
||||
logger.fine("onRelease")
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
}
|
||||
|
||||
private fun throwIfCancelled(functionName: String) {
|
||||
if (cancellationSignal?.isCanceled == true) {
|
||||
logger.warning("Random file access cancelled, throwing ErrnoException(EINTR)")
|
||||
throw ErrnoException(functionName, OsConstants.EINTR)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Exception.toErrNoException(functionName: String) =
|
||||
ErrnoException(
|
||||
functionName,
|
||||
when (this) {
|
||||
is HttpException ->
|
||||
when (code) {
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> OsConstants.EPERM
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> OsConstants.ENOENT
|
||||
else -> OsConstants.EIO
|
||||
}
|
||||
is IndexOutOfBoundsException -> OsConstants.ENXIO // no such [device or] address, see man lseek (2)
|
||||
is InterruptedIOException -> OsConstants.EINTR
|
||||
is PartialContentNotSupportedException -> OsConstants.EOPNOTSUPP
|
||||
else -> OsConstants.EIO
|
||||
},
|
||||
this
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* Responsible for loading (= downloading) a single page from the WebDAV resource.
|
||||
*/
|
||||
inner class PageLoader: CacheLoader<PageIdentifier, ByteArray>() {
|
||||
|
||||
private val jobs = mutableSetOf<Deferred<ByteArray>>()
|
||||
|
||||
fun cancelAll() {
|
||||
for (job in jobs)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
override fun load(key: PageIdentifier): ByteArray {
|
||||
val offset = key.offset
|
||||
val size = key.size
|
||||
logger.fine("Loading page $url $offset/$size")
|
||||
|
||||
// update notification
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, tag = notificationTag) {
|
||||
val progress =
|
||||
if (fileSize == 0L) // avoid division by zero
|
||||
100
|
||||
else
|
||||
(offset * 100 / fileSize).toInt()
|
||||
notification.setProgress(100, progress, false).build()
|
||||
}
|
||||
|
||||
val ifMatch: Headers =
|
||||
documentState.eTag?.let { eTag ->
|
||||
Headers.headersOf("If-Match", "\"$eTag\"")
|
||||
} ?: documentState.lastModified?.let { lastModified ->
|
||||
Headers.headersOf("If-Unmodified-Since", HttpUtils.formatDate(lastModified))
|
||||
} ?: throw DavException("ETag/Last-Modified required for random access")
|
||||
|
||||
// create async job that can be cancelled (and cancellation interrupts I/O)
|
||||
val job = CoroutineScope(Dispatchers.IO).async {
|
||||
runInterruptible {
|
||||
var result: ByteArray? = null
|
||||
dav.getRange(
|
||||
DavUtils.acceptAnything(preferred = mimeType),
|
||||
offset,
|
||||
size,
|
||||
ifMatch
|
||||
) { response ->
|
||||
if (response.code == 200) // server doesn't support ranged requests
|
||||
throw PartialContentNotSupportedException()
|
||||
else if (response.code != 206)
|
||||
throw HttpException(response)
|
||||
|
||||
result = response.body?.bytes()
|
||||
}
|
||||
return@runInterruptible result ?: throw DavException("No response body")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// register job in set so that it can be cancelled
|
||||
jobs += job
|
||||
|
||||
// wait for result
|
||||
return runBlocking {
|
||||
job.await()
|
||||
}
|
||||
} finally {
|
||||
jobs -= job
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class PartialContentNotSupportedException: Exception()
|
||||
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.ProxyFileDescriptorCallback
|
||||
import androidx.annotation.RequiresApi
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper.Companion.TIMEOUT_INTERVAL
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import ru.nsk.kstatemachine.event.Event
|
||||
import ru.nsk.kstatemachine.state.State
|
||||
import ru.nsk.kstatemachine.state.finalState
|
||||
import ru.nsk.kstatemachine.state.initialState
|
||||
import ru.nsk.kstatemachine.state.onEntry
|
||||
import ru.nsk.kstatemachine.state.onExit
|
||||
import ru.nsk.kstatemachine.state.onFinished
|
||||
import ru.nsk.kstatemachine.state.state
|
||||
import ru.nsk.kstatemachine.state.transitionOn
|
||||
import ru.nsk.kstatemachine.statemachine.StateMachine
|
||||
import ru.nsk.kstatemachine.statemachine.createStdLibStateMachine
|
||||
import ru.nsk.kstatemachine.statemachine.processEventBlocking
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/**
|
||||
* (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
|
||||
* the given callback is registered in `com.android.internal.os.AppFuseMount` (which adds it to
|
||||
* a [Map]), but is not unregistered anymore. So it stays in the memory until the whole mount
|
||||
* is unloaded. See https://issuetracker.google.com/issues/208788568
|
||||
*
|
||||
* Use this wrapper to
|
||||
*
|
||||
* - ensure that all memory is released as soon as [onRelease] is called,
|
||||
* - provide timeout functionality: [RandomAccessCallback] will be closed when not
|
||||
*
|
||||
* used for more than [TIMEOUT_INTERVAL] ms and re-created when necessary.
|
||||
*
|
||||
* @param httpClient HTTP client – [RandomAccessCallbackWrapper] is responsible to close it
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class RandomAccessCallbackWrapper @AssistedInject constructor(
|
||||
@Assisted val httpClient: HttpClient,
|
||||
@Assisted val url: HttpUrl,
|
||||
@Assisted val mimeType: MediaType?,
|
||||
@Assisted val headResponse: HeadResponse,
|
||||
@Assisted val cancellationSignal: CancellationSignal?,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val callbackFactory: RandomAccessCallback.Factory
|
||||
): ProxyFileDescriptorCallback() {
|
||||
|
||||
companion object {
|
||||
const val TIMEOUT_INTERVAL = 15000L
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, cancellationSignal: CancellationSignal?): RandomAccessCallbackWrapper
|
||||
}
|
||||
|
||||
sealed class Events {
|
||||
object Transfer : Event
|
||||
object NowIdle : Event
|
||||
object GoStandby : Event
|
||||
object Close : Event
|
||||
}
|
||||
/* We don't use a sealed class for states here because the states would then be singletons, while we can have
|
||||
multiple instances of the state machine (which require multiple instances of the states, too). */
|
||||
private val machine = createStdLibStateMachine {
|
||||
lateinit var activeIdleState: State
|
||||
lateinit var activeTransferringState: State
|
||||
lateinit var standbyState: State
|
||||
lateinit var closedState: State
|
||||
|
||||
initialState("active") {
|
||||
onEntry {
|
||||
_callback = callbackFactory.create(httpClient, url, mimeType, headResponse, cancellationSignal)
|
||||
}
|
||||
onExit {
|
||||
_callback?.onRelease()
|
||||
_callback = null
|
||||
}
|
||||
|
||||
transitionOn<Events.GoStandby> { targetState = { standbyState } }
|
||||
transitionOn<Events.Close> { targetState = { closedState } }
|
||||
|
||||
// active has two nested states: transferring (I/O running) and idle (starts timeout timer)
|
||||
activeIdleState = initialState("idle") {
|
||||
val timer: Timer = Timer(true)
|
||||
var timeout: TimerTask? = null
|
||||
|
||||
onEntry {
|
||||
timeout = timer.schedule(TIMEOUT_INTERVAL) {
|
||||
machine.processEventBlocking(Events.GoStandby)
|
||||
}
|
||||
}
|
||||
onExit {
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
}
|
||||
onFinished {
|
||||
timer.cancel()
|
||||
}
|
||||
|
||||
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
|
||||
}
|
||||
|
||||
activeTransferringState = state("transferring") {
|
||||
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
|
||||
}
|
||||
}
|
||||
|
||||
standbyState = state("standby") {
|
||||
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
|
||||
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
|
||||
transitionOn<Events.Close> { targetState = { closedState } }
|
||||
}
|
||||
|
||||
closedState = finalState("closed")
|
||||
onFinished {
|
||||
shutdown()
|
||||
}
|
||||
|
||||
logger = StateMachine.Logger { message ->
|
||||
this@RandomAccessCallbackWrapper.logger.finer(message())
|
||||
}
|
||||
}
|
||||
|
||||
private val workerThread = HandlerThread(javaClass.simpleName).apply { start() }
|
||||
val workerHandler: Handler = Handler(workerThread.looper)
|
||||
|
||||
private var _callback: RandomAccessCallback? = null
|
||||
|
||||
fun<T> requireCallback(block: (callback: RandomAccessCallback) -> T): T {
|
||||
machine.processEventBlocking(Events.Transfer)
|
||||
try {
|
||||
return block(_callback ?: throw IllegalStateException())
|
||||
} finally {
|
||||
machine.processEventBlocking(Events.NowIdle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// states ///
|
||||
|
||||
@Synchronized
|
||||
private fun shutdown() {
|
||||
httpClient.close()
|
||||
workerThread.quit()
|
||||
}
|
||||
|
||||
|
||||
/// delegating implementation of ProxyFileDescriptorCallback ///
|
||||
|
||||
@Synchronized
|
||||
override fun onFsync() { /* not used */ }
|
||||
|
||||
@Synchronized
|
||||
override fun onGetSize() =
|
||||
requireCallback { it.onGetSize() }
|
||||
|
||||
@Synchronized
|
||||
override fun onRead(offset: Long, size: Int, data: ByteArray) =
|
||||
requireCallback { it.onRead(offset, size, data) }
|
||||
|
||||
@Synchronized
|
||||
override fun onWrite(offset: Long, size: Int, data: ByteArray) =
|
||||
requireCallback { it.onWrite(offset, size, data) }
|
||||
|
||||
@Synchronized
|
||||
override fun onRelease() {
|
||||
machine.processEventBlocking(Events.Close)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.internal.headersContentLength
|
||||
import okio.BufferedSink
|
||||
import java.io.IOException
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* @param client HTTP client– [StreamingFileDescriptor] is responsible to close it
|
||||
*/
|
||||
class StreamingFileDescriptor @AssistedInject constructor(
|
||||
@Assisted val client: HttpClient,
|
||||
@Assisted val url: HttpUrl,
|
||||
@Assisted val mimeType: MediaType?,
|
||||
@Assisted val cancellationSignal: CancellationSignal?,
|
||||
@Assisted val finishedCallback: OnSuccessCallback,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/** 1 MB transfer buffer */
|
||||
private const val BUFFER_SIZE = 1024*1024
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(client: HttpClient, url: HttpUrl, mimeType: MediaType?, cancellationSignal: CancellationSignal?, finishedCallback: OnSuccessCallback): StreamingFileDescriptor
|
||||
}
|
||||
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var transferred: Long = 0
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentText(dav.fileName())
|
||||
.setSmallIcon(R.drawable.ic_storage_notify)
|
||||
.setOngoing(true)
|
||||
val notificationTag = url.toString()
|
||||
|
||||
|
||||
fun download() = doStreaming(false)
|
||||
fun upload() = doStreaming(true)
|
||||
|
||||
private fun doStreaming(upload: Boolean): ParcelFileDescriptor {
|
||||
val (readFd, writeFd) = ParcelFileDescriptor.createReliablePipe()
|
||||
|
||||
val result = CoroutineScope(Dispatchers.IO).async {
|
||||
try {
|
||||
if (upload)
|
||||
uploadNow(readFd)
|
||||
else
|
||||
downloadNow(writeFd)
|
||||
} catch (e: HttpException) {
|
||||
logger.log(Level.WARNING, "HTTP error when opening remote file", e)
|
||||
writeFd.closeWithError("${e.code} ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.INFO, "Couldn't serve file (not necessesarily an error)", e)
|
||||
writeFd.closeWithError(e.message)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
|
||||
try {
|
||||
readFd.close()
|
||||
writeFd.close()
|
||||
} catch (ignored: IOException) {}
|
||||
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
|
||||
finishedCallback.onSuccess(transferred)
|
||||
}
|
||||
|
||||
cancellationSignal?.setOnCancelListener {
|
||||
logger.fine("Cancelling transfer of $url")
|
||||
result.cancel()
|
||||
}
|
||||
|
||||
return if (upload)
|
||||
writeFd
|
||||
else
|
||||
readFd
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible {
|
||||
dav.get(DavUtils.acceptAnything(preferred = mimeType), null) { response ->
|
||||
response.body?.use { body ->
|
||||
if (response.isSuccessful) {
|
||||
val length = response.headersContentLength()
|
||||
|
||||
notification.setContentTitle(context.getString(R.string.webdav_notification_download))
|
||||
if (length == -1L)
|
||||
// unknown file size, show notification now (no updates on progress)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
notification
|
||||
.setProgress(100, 0, true)
|
||||
.build()
|
||||
}
|
||||
else
|
||||
// known file size
|
||||
notification.setSubText(Formatter.formatFileSize(context, length))
|
||||
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
body.byteStream().use { source ->
|
||||
// read first chunk
|
||||
var bytes = source.read(buffer)
|
||||
while (bytes != -1) {
|
||||
// update notification (if file size is known)
|
||||
if (length > 0)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
val progress = (transferred*100/length).toInt()
|
||||
notification
|
||||
.setProgress(100, progress, false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// write chunk
|
||||
output.write(buffer, 0, bytes)
|
||||
transferred += bytes
|
||||
|
||||
// read next chunk
|
||||
bytes = source.read(buffer)
|
||||
}
|
||||
logger.finer("Downloaded $transferred byte(s) from $url")
|
||||
}
|
||||
}
|
||||
|
||||
} else
|
||||
writeFd.closeWithError("${response.code} ${response.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible {
|
||||
val body = object: RequestBody() {
|
||||
override fun contentType(): MediaType? = mimeType
|
||||
override fun isOneShot() = true
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
notification
|
||||
.setContentTitle(context.getString(R.string.webdav_notification_upload))
|
||||
.build()
|
||||
}
|
||||
|
||||
ParcelFileDescriptor.AutoCloseInputStream(readFd).use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
|
||||
// read first chunk
|
||||
var size = input.read(buffer)
|
||||
while (size != -1) {
|
||||
// write chunk
|
||||
sink.write(buffer, 0, size)
|
||||
transferred += size
|
||||
|
||||
// read next chunk
|
||||
size = input.read(buffer)
|
||||
}
|
||||
logger.finer("Uploaded $transferred byte(s) to $url")
|
||||
}
|
||||
}
|
||||
}
|
||||
DavResource(client.okHttpClient, url).put(body) {
|
||||
// upload successful
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun interface OnSuccessCallback {
|
||||
fun onSuccess(transferred: Long)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.DefineComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class WebdavScoped
|
||||
|
||||
@WebdavScoped
|
||||
@DefineComponent(parent = SingletonComponent::class)
|
||||
interface WebdavComponent
|
||||
|
||||
@DefineComponent.Builder
|
||||
interface WebdavComponentBuilder {
|
||||
fun build(): WebdavComponent
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{"metadata":{"generated":"2023-12-03T12:08:52.214Z"},"libraries":[
|
||||
{"uniqueId":"com.example:sample","funding":[],"developers":[{"name":"Sample Developer"}],"artifactVersion":"1.0","description":"This list has to be updated at release build time by explicitly writing to R.raw.aboutlibraries.","name":"Sample Dependency","licenses":["Sample-License"]}
|
||||
], "licenses":{}}
|
||||
@@ -1,449 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">Kasutajakontot ei leidu (enam)</string>
|
||||
<string name="account_title_address_book">DAVx⁵ aadressiraamat</string>
|
||||
<string name="account_prefs_use_app">Palun ära muuda kasutajakontot siin! Selle asemel pruugi kasutajakontode halduseks otseselt rakendust.</string>
|
||||
<string name="dialog_delete">Kustuta</string>
|
||||
<string name="dialog_remove">Eemalda</string>
|
||||
<string name="dialog_deny">Katkesta</string>
|
||||
<string name="field_required">See väli on kohustuslik</string>
|
||||
<string name="help">Abiteave</string>
|
||||
<string name="navigate_up">Liigu üles</string>
|
||||
<string name="optional_label">* valikuline</string>
|
||||
<string name="options_menu">Valikute menüü</string>
|
||||
<string name="share">Jaga</string>
|
||||
<string name="sync_started">Sünkroniseerimine algas või on tööde järjekorras</string>
|
||||
<string name="database_destructive_migration_title">Andmebaas on vigane</string>
|
||||
<string name="database_destructive_migration_text">Kõik kasutajakontod on kohalikust seadmest eemaldatud</string>
|
||||
<string name="notification_channel_debugging">Silumine ja veaotsing</string>
|
||||
<string name="notification_channel_general">Muud olulised sõnumid</string>
|
||||
<string name="notification_channel_status">Väheolulised olekuteated</string>
|
||||
<string name="notification_channel_sync">Sünkroniseerimine</string>
|
||||
<string name="notification_channel_sync_errors">Sünkroniseerimisvead</string>
|
||||
<string name="notification_channel_sync_errors_desc">Olulised vead, mis peatavad sünkroniseerimise, nagu näiteks ootamatud päringuvastused serverist</string>
|
||||
<string name="notification_channel_sync_warnings">Sünkroniseerimishoiatused</string>
|
||||
<string name="notification_channel_sync_warnings_desc">Vähetõsised sünkroniseerimisteated näiteks vigaste failide kohta</string>
|
||||
<string name="notification_channel_sync_io_errors">Võrgu- ja sisend/väljundvead</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">Ühenduste aegumine ja muud sarnased probleemid (tihti ajutised)</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">Sinu andmed. Sinu valik.</string>
|
||||
<string name="intro_slogan2">Sina otsustad.</string>
|
||||
<string name="intro_battery_title">Regulaarne sünkroniseerimisvälp</string>
|
||||
<string name="intro_battery_text">Selleks, et sünkroniseerimine soovitud ajavahemike järel toimiks taustateenusena, vajab %s õigust töötada taustal. Vastasel juhul võib Android igal ajal sünkroniseerimise peatada.</string>
|
||||
<string name="intro_battery_dont_show">Ma ei soovi kasutada regulaarset sünkroniseerimisvälpa. *</string>
|
||||
<string name="intro_autostart_title">%s ühilduvus</string>
|
||||
<string name="intro_autostart_text">Ilmselt see nutiseade blokeerib sünkroniseerimist. Kui see sinu tegevust mõjutab, siis saad olukorra lahendada käsitsi.</string>
|
||||
<string name="intro_autostart_dont_show">Ma juba kasutan nõutavaid seadistusi. Ära enam tuleta seda mulle meelde.*</string>
|
||||
<string name="intro_leave_unchecked">* Kui soovid hilisemat meeldetuletust, jäta see märkimata. Lisaks saad seada muuta rakenduse seadistustest / %s.</string>
|
||||
<string name="intro_more_info">Lisateave</string>
|
||||
<string name="intro_tasks_jtx">jtx Board</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[Toetab ülesannete, märkmete ja päevikute sünkroniseerimist.]]></string>
|
||||
<string name="intro_tasks_title">Ülesannete tugi</string>
|
||||
<string name="intro_tasks_text1">Kui sinu kasutatav server toetab ülesannete haldust, siis nende sünkroniseerimine on võimalik toetatud ülesannete rakendusega:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks</string>
|
||||
<string name="intro_tasks_opentasks_info">Tundub, et arendus on lõppenud ja seega pole kasutamine enam mõistlik.</string>
|
||||
<string name="intro_tasks_tasks_org">Tasks.org</string>
|
||||
<string name="intro_tasks_tasks_org_info"><![CDATA[Mõned funktsionaalsused <a href="https://www.davx5.com/faq/tasks/advanced-task-features">pole toetatud</a>.]]></string>
|
||||
<string name="intro_tasks_no_app_store">Rakendustepoodi pole saadaval</string>
|
||||
<string name="intro_tasks_dont_show">Ma ei vaja ülesannete tuge.*</string>
|
||||
<string name="intro_open_source_title">Avatud lähtekoodiga tarkvara</string>
|
||||
<string name="intro_open_source_text">Me oleme rõõmsad, et kasutad avatud lähtekoodil põhinevat rakendust %s. Selle arendus, hooldus ja kasutajatugi nõuavad märgatavat tööd. Palun kaalu erinevaid võimalusi osalemiseks või rahalist toetamist. Me hindaksime seda väga!</string>
|
||||
<string name="intro_open_source_details">Võimalused kaastööks või rahaliseks toetamiseks</string>
|
||||
<string name="intro_open_source_dont_show">Ära näita seda lähiajal uuesti</string>
|
||||
<string name="intro_next">Järgmine</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_title">Õigused</string>
|
||||
<string name="permissions_text">%s vajab korralikuks toimimiseks õigusi.</string>
|
||||
<string name="permissions_all_title">Kõik alljärgnev</string>
|
||||
<string name="permissions_all_status_off">Kasuta seda valikut kõikide funktsionaalsuste sisselülitamiseks (soovitatav)</string>
|
||||
<string name="permissions_all_status_on">Rakenduse õigused on olemas</string>
|
||||
<string name="permissions_contacts_title">Kontaktide õigused</string>
|
||||
<string name="permissions_contacts_status_off">Kontaktide sünkroniseerimine puudub (pole soovitatud)</string>
|
||||
<string name="permissions_contacts_status_on">Kontaktide sünkroniseerimine on võimalik</string>
|
||||
<string name="permissions_calendar_title">Kalendri õigused</string>
|
||||
<string name="permissions_calendar_status_off">Kalendri sünkroniseerimine puudub (pole soovitatud)</string>
|
||||
<string name="permissions_calendar_status_on">Kalendri sünkroniseerimine on võimalik</string>
|
||||
<string name="permissions_notification_title">Teavituste õigused</string>
|
||||
<string name="permissions_notification_status_off">Teavitused pole kasutusel (pole soovitatav)</string>
|
||||
<string name="permissions_notification_status_on">Teavitused on kasutusel</string>
|
||||
<string name="permissions_jtx_title">Õigused - jtx Board</string>
|
||||
<string name="permissions_opentasks_title">Õigused - OpenTasks</string>
|
||||
<string name="permissions_tasksorg_title">Ülesannete õigused</string>
|
||||
<string name="permissions_tasks_status_off">Ülesannete sünkroniseerimine puudub</string>
|
||||
<string name="permissions_tasks_status_on">Ülesannete sünkroniseerimine on võimalik</string>
|
||||
<string name="permissions_autoreset_title">Säilita õigused</string>
|
||||
<string name="permissions_autoreset_status_off">Õigusi võib muuta automaatselt (pole soovitatud)</string>
|
||||
<string name="permissions_autoreset_status_on">Õigused ei saa olema automaatselt muudetud</string>
|
||||
<string name="permissions_autoreset_instruction">Klõpsi Õigused ja eemalda valik „Eemalda load, kui rakendust ei kasutata“</string>
|
||||
<string name="permissions_app_settings_hint">Kui muutmine ei toimi, siis kasuta rakenduse õiguste seadistusi.</string>
|
||||
<string name="permissions_app_settings">Rakenduse seadistused</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<string name="wifi_permissions_label">WiFi SSID õigused</string>
|
||||
<string name="wifi_permissions_intro">Selleks, et toimiks ligipääs hetkel kasutatavale WiFi võrgunimele (SSID), peavad olema täidetud järgnevad tingimused:</string>
|
||||
<string name="wifi_permissions_location_permission">Õigused täpse asukoha tuvastamiseks</string>
|
||||
<string name="wifi_permissions_location_permission_on">Õigused asukoha tuvastamiseks on olemas</string>
|
||||
<string name="wifi_permissions_location_permission_off">Õigused asukoha tuvastamiseks on keelatud</string>
|
||||
<string name="wifi_permissions_background_location_permission">Õigused asukoha tuvastamiseks taustal</string>
|
||||
<string name="wifi_permissions_background_location_permission_label">Luba alati</string>
|
||||
<string name="wifi_permissions_background_location_permission_on">Asukohaõigused on: %s</string>
|
||||
<string name="wifi_permissions_background_location_permission_off">Asukohaõiguseid pole: %s</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer">%s kasutab asukohaandmeid (vaid WiFi SSID võrgutunnust) vaid sünkroniseerimise tagamiseks konkreetse WiFi-võrgu piires. See kehtib ka siis, kui sünkroniseerimine on seadistatud töötama taustal.</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer2">Kõik asukohaandmed (vaid WiFi SSId võrgutunnus) on kasutusel kohalikus nutiseadmes ega saadeta mitte kuhugile mujale.</string>
|
||||
<string name="wifi_permissions_location_enabled">Asukohateenus on alati kasutusel</string>
|
||||
<string name="wifi_permissions_location_enabled_on">Asukohateenus on lubatud</string>
|
||||
<string name="wifi_permissions_location_enabled_off">Asukohateenus pole lubatud</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_translations">Tõlked</string>
|
||||
<string name="about_libraries">Teegid</string>
|
||||
<string name="about_version">Versioon %1$s (%2$d)</string>
|
||||
<string name="about_copyright">© Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) ja kaasautorid</string>
|
||||
<string name="about_license_info_no_warranty">Selle rakenduse kasutamisega EI KAASNE MITTE ÜHTEGI GARANTIID. Tegemist on vaba ja avatud tarkvaraga ning sa võid seda levitada kindlate tingimuste alusel.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">Logifaili loomine ei õnnestunud</string>
|
||||
<string name="logging_notification_text">Nüüd logime kõiki %s rakenduse tegevusi</string>
|
||||
<string name="logging_notification_view_share">Vaata/jaga</string>
|
||||
<string name="logging_notification_disable">Lülita välja</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV sünkroniseerimise sobitaja</string>
|
||||
<string name="navigation_drawer_about">Teave / litsents</string>
|
||||
<string name="navigation_drawer_beta_feedback">Beetaversiooni tagasiside</string>
|
||||
<string name="install_browser">Palun paigalda veebibrauser</string>
|
||||
<string name="navigation_drawer_settings">Seadistused</string>
|
||||
<string name="navigation_drawer_news_updates">Uudised ja uuendused</string>
|
||||
<string name="navigation_drawer_tools">Tarvikud</string>
|
||||
<string name="navigation_drawer_external_links">Välised lingid</string>
|
||||
<string name="navigation_drawer_website">Veebisait</string>
|
||||
<string name="navigation_drawer_manual">Käsiraamat</string>
|
||||
<string name="navigation_drawer_faq">KKK</string>
|
||||
<string name="navigation_drawer_community">Kogukond</string>
|
||||
<string name="navigation_drawer_support_project">Toeta projekti</string>
|
||||
<string name="navigation_drawer_contribute">Osalemise viisid</string>
|
||||
<string name="navigation_drawer_privacy_policy">Privaatsuspoliitika</string>
|
||||
<string name="account_list_no_notification_permission">Teavitused on väljalülitatud ja seega sünkroniseerimisvigade infot sa ei näe.</string>
|
||||
<string name="account_list_manage_connections">Halda ühendusi</string>
|
||||
<string name="account_list_datasaver_enabled">Andmemahu piiraja on kasutusel. Taustal sünkroniseerimine võib toimida piirangutega.</string>
|
||||
<string name="account_list_manage_datasaver">Halda andmemahu piirajat</string>
|
||||
<string name="account_list_battery_saver_enabled">Akukasutuse piiraja on kasutusel. Taustal sünkroniseerimine võib toimida piirangutega.</string>
|
||||
<string name="account_list_manage_battery_saver">Halda akukasutuse piirajat</string>
|
||||
<string name="account_list_low_storage">Vaba andmeruumi napib. Android ei sünkroniseeri kohalikke muudatusi kohe, vaid järgmise regulaarse sünkroniseerimise ajal.</string>
|
||||
<string name="account_list_manage_storage">Halda andmeruumi</string>
|
||||
<string name="account_list_welcome">Tere tulemast kasutama rakendust DAVx⁵!</string>
|
||||
<string name="account_list_empty">Loo ühendus oma serveriga ja hoia kalendrid ning kontaktid sünkroniseerituna.</string>
|
||||
<string name="accounts_sync_all">Sünkroniseeri kõik kasutajakontod</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">Teenuse tuvastamine ei õnnestunud</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">Kogumike loendi uuendamine ei õnnestunud</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<string name="foreground_service_notify_title">Töötame esiplaanil</string>
|
||||
<string name="foreground_service_notify_text">See eelistus on vajalik sünkroniseerimiseks mõnedes seadmetes.</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Seadistused</string>
|
||||
<string name="app_settings_debug">Silumine ja veaotsing</string>
|
||||
<string name="app_settings_show_debug_info">Näita silumisteavet</string>
|
||||
<string name="app_settings_show_debug_info_details">Vaata/jaga seadistuse üksikasju ja logisid</string>
|
||||
<string name="app_settings_logging">Väga üksikasjalik logimine</string>
|
||||
<string name="app_settings_logging_on">Logimine on kasutusel. Silumisteabe osana saad vaadata logisid.</string>
|
||||
<string name="app_settings_logging_off">Logimine pole kasutusel</string>
|
||||
<string name="app_settings_battery_optimization">Akukasutuse optimeerimine</string>
|
||||
<string name="app_settings_battery_optimization_exempted">See rakendus ei allu akukasutuse optimeerimisele (soovitatav valik)</string>
|
||||
<string name="app_settings_battery_optimization_optimized">Akukasutuse optimeerimise piirangud on kasutusel (mittesoovitatav valik)</string>
|
||||
<string name="app_settings_connection">Ühendus</string>
|
||||
<string name="app_settings_proxy">Proksiserveri tüüp</string>
|
||||
<string-array name="app_settings_proxy_types">
|
||||
<item>Süsteemi proksiserver</item>
|
||||
<item>Proksiserver puudub</item>
|
||||
<item>HTTP</item>
|
||||
<item>SOCKS (Orboti jaoks)</item>
|
||||
</string-array>
|
||||
<string name="app_settings_proxy_host">Proksiserveri hostinimi</string>
|
||||
<string name="app_settings_proxy_port">Proksiserveri port</string>
|
||||
<string name="app_settings_security">Turvalisus</string>
|
||||
<string name="app_settings_security_app_permissions">Rakenduse õigused</string>
|
||||
<string name="app_settings_security_app_permissions_summary">Täpsusta sünkroniseerimiseks vajalike õigusi</string>
|
||||
<string name="app_settings_distrust_system_certs">Ära usalda nutiseadme süsteemseid sertifikaate</string>
|
||||
<string name="app_settings_distrust_system_certs_on">Süsteemsed ja kasutaja lisatud sertifitseerimiskeskused ei ole usaldatud</string>
|
||||
<string name="app_settings_distrust_system_certs_off">Süsteemsed ja kasutaja lisatud sertifitseerimiskeskused on usaldatud (soovitatav valik)</string>
|
||||
<string name="app_settings_reset_certificates">Lähtesta (mitte)usaldatud sertifikaatide loend</string>
|
||||
<string name="app_settings_reset_certificates_summary">Selle valikuga eemaldatakse kõik sinu lisatud sertifikaatide usaldusmärked</string>
|
||||
<string name="app_settings_reset_certificates_success">Kõik sinu lisatud sertifikaatide usaldusmärked on eemaldatud</string>
|
||||
<string name="app_settings_user_interface">Kasutajaliides</string>
|
||||
<string name="app_settings_notification_settings">Teavituste seadistused</string>
|
||||
<string name="app_settings_notification_settings_summary">Halda teavituskanaleid ja nende seadistusi</string>
|
||||
<string name="app_settings_theme_title">Vali kujundus</string>
|
||||
<string-array name="app_settings_theme_names">
|
||||
<item>Süsteemi kujundus</item>
|
||||
<item>Hele kujundus</item>
|
||||
<item>Tume kujundus</item>
|
||||
</string-array>
|
||||
<string name="app_settings_reset_hints">Lähtesta vihjed</string>
|
||||
<string name="app_settings_reset_hints_summary">Lülitab varem väljalülitatud vihtjete kuvamise uuesti sisse</string>
|
||||
<string name="app_settings_reset_hints_success">Näitame jälle kõiki vihjeid</string>
|
||||
<string name="app_settings_integration">Lõimimine</string>
|
||||
<string name="app_settings_tasks_provider">Ülesannete rakendus</string>
|
||||
<string name="app_settings_tasks_provider_none">Ühilduvat ülesannete rakendust ei leidu</string>
|
||||
<string name="app_settings_unifiedpush">UnifiedPush (katseline)</string>
|
||||
<string name="app_settings_unifiedpush_disable">Puudub (tõuketeenuseid pole)</string>
|
||||
<string name="app_settings_unifiedpush_choose_distributor">Vali levitaja</string>
|
||||
<string name="app_settings_unifiedpush_no_distributor">Ühtegi tõukesõnumite levitajat pole paigaldatud</string>
|
||||
<string name="app_settings_unifiedpush_no_endpoint">Otspunkt on seadistamata</string>
|
||||
<string name="app_settings_unifiedpush_ready">Valmis tõuketeadete vastuvõtmiseks %s vahendusel</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_missing_permissions">Nende kogumike sünkroniseerimiseks on vajalikud täiendavad õigused.</string>
|
||||
<string name="account_manage_permissions">Halda õigusi</string>
|
||||
<string name="account_synchronize_now">Sünkroniseeri nüüd</string>
|
||||
<string name="account_settings">Kasutajakonto seadistused</string>
|
||||
<string name="account_rename">Muuda kasutajakonto nime</string>
|
||||
<string name="account_rename_new_name_description">Salvestamata kohalik teave võib vahele jääda. Peale nime muutmist palun sünkroniseeri uuesti.</string>
|
||||
<string name="account_rename_new_name">Kasutajakonto uus nimi</string>
|
||||
<string name="account_rename_rename">Muuda nime</string>
|
||||
<string name="account_rename_exists_already">Selline nimi on juba kasutusel</string>
|
||||
<string name="account_rename_couldnt_rename">Kasutajakonto nime muutmine ei õnnestunud</string>
|
||||
<string name="account_delete">Kustuta kasutajakonto</string>
|
||||
<string name="account_delete_confirmation_title">Kas tõesti kustutame kasutajakonto?</string>
|
||||
<string name="account_delete_confirmation_text">Sellega kustutame ka kõik aadresside, kalendrite ja ülesannete kohalikud koopiad.</string>
|
||||
<string name="account_synchronize_this_collection">sünkroniseeri see kogumik</string>
|
||||
<string name="account_read_only">ainult lugemisõigus</string>
|
||||
<string name="account_calendar">kalender</string>
|
||||
<string name="account_contacts">kontaktid</string>
|
||||
<string name="account_journal">päevik</string>
|
||||
<string name="account_task_list">ülesanded</string>
|
||||
<string name="account_only_personal">Näita vaid isiklikke</string>
|
||||
<string name="account_refresh_collections">Uuenda loendit</string>
|
||||
<string name="account_webcal_external_app">Webcali tellimusi on võimalik sünkroniseerida väliste rakendustega.</string>
|
||||
<string name="account_no_webcal_handler_found">Webcaliga ühilduvaid rakendusi ei leidu</string>
|
||||
<string name="account_install_icsx5">Paigalda ICSx⁵</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Lisa kasutajakonto</string>
|
||||
<string name="login_privacy_hint"><![CDATA[Kõik andmed liiguvad vaid sinu serveri ja sinu nutiseadme vahel. %1$s ei saada neid mitte kuhugile mujale. Lisateavet leiad <a href="%2$s">meie privaatsuspoliitikast</a>.]]></string>
|
||||
<string name="login_generic_login">Üldine sisselogimine</string>
|
||||
<string name="login_provider_login">Teenusepakkujakohane sisselogimine</string>
|
||||
<string name="login_continue">Jätka</string>
|
||||
<string name="login_login">Logi sisse</string>
|
||||
<string name="login_type_email">Logi sisse e-posti aadressiga</string>
|
||||
<string name="login_email_address">E-posti aadress</string>
|
||||
<string name="login_email_address_error">Nõutav on korrektne e-posti aadress</string>
|
||||
<string name="login_email_address_info"><![CDATA[E-posti aadressi domeeni alusel leiame alustuseks mõeldud võrguaadressi. <a href="%s">Teenused tuvastame</a> nimeserveri kirjete ning „.well-known“ tunnusaadresside abil.]]></string>
|
||||
<string name="login_password">Salasõna</string>
|
||||
<string name="login_password_hide">Peida salasõna</string>
|
||||
<string name="login_password_show">Näita salasõna</string>
|
||||
<string name="login_password_optional">Salasõna*</string>
|
||||
<string name="login_type_url">Logi sisse võrguaadressi ja kasutajanimega</string>
|
||||
<string name="login_user_name">Kasutajanimi</string>
|
||||
<string name="login_user_name_optional">Kasutajanimi*</string>
|
||||
<string name="login_base_url">Alustuseks mõeldud võrguaadress</string>
|
||||
<string name="login_base_url_info"><![CDATA[Kontrollime alustuseks mõeldud võrguaadressi ka, aga lisaks <a href="%s">tuvastame teenuseid</a> nimeserveri kirjete ning „.well-known“ tunnusaadresside abil.]]></string>
|
||||
<string name="login_select_certificate">Vali sertifikaat</string>
|
||||
<string name="login_add_account">Lisa kasutajakonto</string>
|
||||
<string name="login_account_name">Kasutajakonto nimi</string>
|
||||
<string name="login_account_avoid_apostrophe">Ülakomade (\') kasutamine tundub mõnedes seadmetes tekitama probleeme.</string>
|
||||
<string name="login_account_name_info">Kuna Android pruugib kasutajakonto nime sinu loodavate ürituste Korraldaja ehk ORGANIZER välja väärtustamiseks, siis soovitame, et sinu kasutajakonto nimi on sinu e-posti aadress. Palun arvesta, et sul ei saa olla kahte samanimelist kasutajakontot.</string>
|
||||
<string name="login_account_contact_group_method">Kontaktgrupi meetod:</string>
|
||||
<string name="login_account_name_required">Kasutajakonto nimi on nõutav</string>
|
||||
<string name="login_account_name_already_taken">Selline nimi on juba kasutusel</string>
|
||||
<string name="login_account_not_added">Kasutajakonto lisamine ei õnnestunud</string>
|
||||
<string name="login_finish">Lõpeta</string>
|
||||
<string name="login_type_advanced">Täiendavad sisselogimise seadistused</string>
|
||||
<string name="login_no_client_certificate_optional">Kliendi sertifikaat puudub*</string>
|
||||
<string name="login_client_certificate_selected">Kliendi sertifikaat: %s</string>
|
||||
<string name="login_no_certificate_found">Kliendisertifikaati ei leidunud</string>
|
||||
<string name="login_install_certificate">Paigalda sertifikaat</string>
|
||||
<string name="login_type_google">Google\'i Kontaktid / Kalender</string>
|
||||
<string name="login_google_see_tested_with">Uuendatud teavet leiad meie „Tested with Google“ lehelt.</string>
|
||||
<string name="login_google_unexpected_warnings">Võib tekkida ootamatuid vigu ja/või sa pead looma oma klienditunnuse.</string>
|
||||
<string name="login_google_account">Google\'i kasutajakonto</string>
|
||||
<string name="login_google">Logi sisse Google\'i kasutajakontoga</string>
|
||||
<string name="login_google_client_id">Klienditunnus (kui soovid lisada)</string>
|
||||
<string name="login_google_client_privacy_policy"><![CDATA[%1$s teisaldab sinu Google\'i kontaktide ja kalendri andmeid vaid sünkroniseerimiseks selles seadmes. Lisateavet leiad meie <a href="%2$s">Privaatsuspoliitikast</a>.]]></string>
|
||||
<string name="login_google_client_limited_use"><![CDATA[%1$s järgib <a href="%2$s">Google\'i API teenuste kasutajaandmete poliitikat</a>, sealhulgas piiratud kasutuse nõudeid.]]></string>
|
||||
<string name="login_oauth_couldnt_obtain_auth_code">Autoriseerimiskoodi saamine polnud võimalik</string>
|
||||
<string name="login_type_nextcloud">Nextcloud</string>
|
||||
<string name="login_nextcloud_login_with_nextcloud">Logi sisse Nextcloudi kontoga</string>
|
||||
<string name="login_nextcloud_login_flow_text">Selle eelistusega käivitad Nextcloudi sisselogimise veebibrauseris.</string>
|
||||
<string name="login_nextcloud_login_flow_server_address">Nextcloudi serveri aadress</string>
|
||||
<string name="login_nextcloud_login_flow_sign_in">Logi sisse</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_url">Sisselogimise võrguaadressi tuvastamine polnud võimalik</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_data">Sisselogimisandmete tuvastamine polnud võimalik</string>
|
||||
<string name="login_configuration_detection">Seadistuste tuvastamine</string>
|
||||
<string name="login_querying_server">Palun oota, pärime andmeid serverist…</string>
|
||||
<string name="login_no_service">Ei õnnestunud leida CalDAV või CardDAV teenust.</string>
|
||||
<string name="login_no_service_info">Antud võrguaadress ei tundu olema ligipääsetav CalDAVi/CardDAVi võrguaadress ja teenuse tuvastamine ei õnnestunud.</string>
|
||||
<string name="login_see_tested_services"><![CDATA[Lisateavet leidad oma teenusepakkuja juhendist ja <a href="%s">meie poolt testitud teenuste loendist</a> koos toimivate võrguaadressidega.]]></string>
|
||||
<string name="login_check_credentials">Palun samuti topeltkontrolli autentimist (tavaliselt kasutajanimi ja salasõna)</string>
|
||||
<string name="login_logs_available">Täiendav tehniline teade leidub logides.</string>
|
||||
<string name="login_view_logs">Vaata logisid</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">Sünkroniseerimine</string>
|
||||
<string name="settings_sync_interval_contacts">Kontaktide sünkroniseerimise välp</string>
|
||||
<string name="settings_sync_summary_manually">Vaid käsitsi</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Iga %d minuti järel + kohalikud muudatused koheselt</string>
|
||||
<string name="settings_sync_interval_calendars">Kalendrite sünkroniseerimise välp</string>
|
||||
<string name="settings_sync_interval_tasks">Ülesannete sünkroniseerimise välp</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>Vaid käsitsi</item>
|
||||
<item>Iga 15 minuti järel</item>
|
||||
<item>Iga 30 minuti järel</item>
|
||||
<item>Kord tunnis</item>
|
||||
<item>Iga 2 tunni järel</item>
|
||||
<item>Iga 4 tunni järel</item>
|
||||
<item>Kord päevas</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">Sünkroniseeri vaid WiFi ühendusega</string>
|
||||
<string name="settings_sync_wifi_only_on">Sünkroniseerimine on lubatud vaid WiFi ühendusega</string>
|
||||
<string name="settings_sync_wifi_only_off">Ühenduse liik pole oluline</string>
|
||||
<string name="settings_sync_wifi_only_ssids">WiFi SSID piirangud</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Sünkroniseeri vaid %s võrgus</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Kasuta kõiki WiFi ühendusi</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">Lubatud WiFi võrgunimede (SSID) komadega eraldatud loend (kui jätad tühjaks on kõik lubatud)</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_required">WiFi SSID piirang vajab täiendavat saedistamist</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">Halda</string>
|
||||
<string name="settings_ignore_vpns">VPNi kasutamine eeldab, et võrguühendus toimib</string>
|
||||
<string name="settings_ignore_vpns_on">VPN ilma toimiva ja kontrollitud internetiühenduseta pole piisav sünkroniseerimiseks (soovitatud)</string>
|
||||
<string name="settings_ignore_vpns_off">VPN ilma toimiva ja kontrollitud internetiühenduseta on sünkroniseerimiseks piisav</string>
|
||||
<string name="settings_authentication">Autentimine</string>
|
||||
<string name="settings_username">Kasutajanimi</string>
|
||||
<string name="settings_password">Salasõna</string>
|
||||
<string name="settings_new_password">Uus salasõna</string>
|
||||
<string name="settings_password_summary">Uuenda salasõna vastavalt oma serveri juhendile.</string>
|
||||
<string name="settings_certificate_alias">Kliendi sertifikaat</string>
|
||||
<string name="settings_certificate_alias_empty">Sertifikaati pole saadaval või paigaldatud</string>
|
||||
<string name="settings_certificate_install">Paigalda sertifikaat</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Möödunud sündmuste ajapiir</string>
|
||||
<string name="settings_sync_time_range_past_none">Kõik sündmused kuuluvad sünkroniseerimisele</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Eira enam kui üks päev vanu sündmuseid</item>
|
||||
<item quantity="other">Eira enam kui %d päeva vanu sündmuseid</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Sündmused, mis on vanemad, kui siin märgitud päevade arv, jäävad sünkroniseerimata (võib olla ka 0). Kõikide sündmuste sünkroniseerimiseks jäta tühjaks.</string>
|
||||
<string name="settings_default_alarm">Vaikimisi meeldetuletus</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="one">Vaikimisi meeldetuletus üks minutit enne sündmust</item>
|
||||
<item quantity="other">Vaikimisi meeldetuletus %d minutit enne sündmust</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">Vaikimisi meeldetuletused puuduvad</string>
|
||||
<string name="settings_default_alarm_message">Eelistus määrab, kas kasutame vaikimisi meeldetuletust sündmuste puhul, kus eraldi meeldetuletus on seadistamata. Aktiveerimiseks sisesta vaikimisi meeldetuletuse aeg minutites. Väljalülitamiseks jäta tühjaks.</string>
|
||||
<string name="settings_manage_calendar_colors">Halda kalendrivärve</string>
|
||||
<string name="settings_manage_calendar_colors_on">Kalendri värvid lähtestatakse igal sünkroniseerimisel</string>
|
||||
<string name="settings_manage_calendar_colors_off">Muud rakendused võivad kalendrivärve seadistada</string>
|
||||
<string name="settings_event_colors">Sündmuste värvide tugi</string>
|
||||
<string name="settings_event_colors_on">Sündmuste värvid kuuluvad sünkroniseerimisele</string>
|
||||
<string name="settings_event_colors_off">Sündmuste värvid ei kuulu sünkroniseerimisele</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Kontaktgrupi meetod</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>Grupid on eraldi vCard-kirjed</item>
|
||||
<item>Grupid on kontaktikohased kategooriad</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">Loo aadressiraamat</string>
|
||||
<string name="create_addressbook_maybe_not_supported">See server ei pruugi toetada aadressiraamatu loomist CardDAVi ühenduse abil.</string>
|
||||
<string name="create_calendar">Loo kalender</string>
|
||||
<string name="create_calendar_time_zone_optional">Vaikimisi ajavöönd*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">Võimalikud kalendrikirjed</string>
|
||||
<string name="create_calendar_type_vevent">Sündmused</string>
|
||||
<string name="create_calendar_type_vtodo">Ülesanded</string>
|
||||
<string name="create_calendar_type_vjournal">Märkmed / päevik</string>
|
||||
<string name="create_calendar_maybe_not_supported">See server ei pruugi toetada kalendri loomist CalDAVi ühenduse abil.</string>
|
||||
<string name="create_collection_color">Värv</string>
|
||||
<string name="create_collection_display_name">Pealkiri</string>
|
||||
<string name="create_collection_home_set">Andmeruumi asukoht</string>
|
||||
<string name="create_collection_description_optional">Kirjeldus*</string>
|
||||
<string name="create_collection_create">Loo</string>
|
||||
<string name="create_collection_optional">* valikuline</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">Kustuta kogumik</string>
|
||||
<string name="collection_delete_warning">See kogumik (%s) koos oma kõikide andmetega kustutatakse nüüd jäädavalt nii serverist, kui kohalikust nutiseadmest.</string>
|
||||
<string name="collection_synchronization">Sünkroniseerimine</string>
|
||||
<string name="collection_synchronization_on">Sünkroniseerimine on kasutusel</string>
|
||||
<string name="collection_synchronization_off">Sünkroniseerimine pole kasutusel</string>
|
||||
<string name="collection_read_only">Ainult lugemisõigus</string>
|
||||
<string name="collection_read_only_by_server">Ainult lugemisõigus (serveri poolt)</string>
|
||||
<string name="collection_read_only_by_setting">Ainult lugemisõigus (reeglite alusel)</string>
|
||||
<string name="collection_read_only_forced">Ainult lugemisõigus (ainult kohalikus nutiseadmes)</string>
|
||||
<string name="collection_read_write">Lugemis- ja kirjutamisõigus</string>
|
||||
<string name="collection_title">Pealkiri</string>
|
||||
<string name="collection_description">Kirjeldus</string>
|
||||
<string name="collection_owner">Omanik</string>
|
||||
<string name="collection_push_support">Tõuketeenuse tugi</string>
|
||||
<string name="collection_push_web_push">Server teavitab tõuketeenuse toe olemasolust</string>
|
||||
<string name="collection_push_subscribed_at">Tellitud %1$s, aegub %2$s</string>
|
||||
<string name="collection_last_sync">Viimane sünkroniseerimine (%s)</string>
|
||||
<string name="collection_url">Aadress (võrguaadress)</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Silumisteave</string>
|
||||
<string name="debug_info_archive_caption">ZIP-arhiivifail</string>
|
||||
<string name="debug_info_archive_subtitle">Sisaldab silumisteavet ja logisid</string>
|
||||
<string name="debug_info_archive_text">Tõsta arhiiv uurimiseks arvutisse, saada huvilisele e-postiga või lisa veateatele meie veahalduses.</string>
|
||||
<string name="debug_info_archive_share">Jaga arhiivi</string>
|
||||
<string name="debug_info_attached">Sõnumile lisatud silumisteave (eeldab, et vastuvõttev rakendus oskab manuseid käsitleda).</string>
|
||||
<string name="debug_info_http_error">HTTP-viga</string>
|
||||
<string name="debug_info_server_error">Serveri viga</string>
|
||||
<string name="debug_info_webdav_error">WebDAVi viga</string>
|
||||
<string name="debug_info_io_error">Sisend-/väljundviga</string>
|
||||
<string name="debug_info_http_403_description">Osapool keeldus päringule vastamast. Kontrolli asjaomaseid teenuseid ja tarvikuid ning üksikasjalikku teavet võid leida silumislogidest.</string>
|
||||
<string name="debug_info_http_404_description">Soovitud teenust või tarvikud pole (enam) olemas. Kontrolli asjaomaseid teenuseid ja tarvikuid ning üksikasjalikku teavet võid leida silumislogidest.</string>
|
||||
<string name="debug_info_http_5xx_description">Tekkis serveripoolne viga. Palun võta ühendust serveri haldajaga.</string>
|
||||
<string name="debug_info_unexpected_error">Tekkis ootamatu viga. Lisainfot leiad silumisteabest.</string>
|
||||
<string name="debug_info_view_details">Vaata üksikasju</string>
|
||||
<string name="debug_info_subtitle">Silumisteave on kogutud</string>
|
||||
<string name="debug_info_involved_caption">Seotud teenused ja tarvikud</string>
|
||||
<string name="debug_info_involved_subtitle">Probleemi või veaga seotud teave</string>
|
||||
<string name="debug_info_involved_remote">Serveris asuvad teenused ja tarvikud:</string>
|
||||
<string name="debug_info_involved_local">Kohalikus nutiseadmes teenused ja tarvikud:</string>
|
||||
<string name="debug_info_logs_caption">Logid</string>
|
||||
<string name="debug_info_logs_subtitle">Saadaval on üksikasjalikud logid</string>
|
||||
<string name="debug_info_logs_view">Vaata logisid</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Tekkis viga.</string>
|
||||
<string name="exception_httpexception">Tekkis http-viga.</string>
|
||||
<string name="exception_ioexception">Tekkis sisend-väljundviga.</string>
|
||||
<string name="exception_show_details">Näita üksikasju</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_mounts_title">WebDAVi haakepunktid</string>
|
||||
<string name="webdav_mounts_quota_used_available">Kasutatud mahukvoot: %1$s / saadaval: %2$s</string>
|
||||
<string name="webdav_mounts_share_content">Jaga sisu</string>
|
||||
<string name="webdav_mounts_unmount">Eemalda haakimine</string>
|
||||
<string name="webdav_add_mount_title">Lisa WebDAVi haakepunkt</string>
|
||||
<string name="webdav_mounts_empty">Otseligipääs sinu failidele WebDAVi haakepunktist!</string>
|
||||
<string name="webdav_add_mount_empty_more_info"><![CDATA[Vaata juhendit <a href="%1$s">kuidas WebDAVi haakepunktid toimivad</a>.</string>]]></string>
|
||||
<string name="webdav_add_mount_display_name">Kuvatav nimi</string>
|
||||
<string name="webdav_add_mount_url">WebDAVi võrguaadress</string>
|
||||
<string name="webdav_add_mount_url_invalid">Vigane võrguaadress</string>
|
||||
<string name="webdav_add_mount_authentication">Autentimine (kui on vaja)</string>
|
||||
<string name="webdav_add_mount_username">Kasutajanimi</string>
|
||||
<string name="webdav_add_mount_password">Salasõna</string>
|
||||
<string name="webdav_add_mount_add">Lisa haakepunkt</string>
|
||||
<string name="webdav_add_mount_no_support">Sellel võrguaadressil ei leidu WebDAVi teenust</string>
|
||||
<string name="webdav_remove_mount_title">Eemalda haakepunkt</string>
|
||||
<string name="webdav_remove_mount_text">Ühenduse andmed lähevad kaotsi, aga ühtegi faili ei kustutata.</string>
|
||||
<string name="webdav_notification_access">Ligipääs WebDAVi failile</string>
|
||||
<string name="webdav_notification_download">Laadime WebDAVi faili alla</string>
|
||||
<string name="webdav_notification_upload">Laadime WebDAVi faili üles</string>
|
||||
<string name="webdav_provider_root_title">WebDAVi haakepunkt</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">DAVx⁵ õigused</string>
|
||||
<string name="sync_error_permissions_text">Vajalikud on täiendavad õigused</string>
|
||||
<string name="sync_error_tasks_too_old">%s on liiga vana</string>
|
||||
<string name="sync_error_tasks_required_version">Väikseim nõutav versioon: %1$s</string>
|
||||
<string name="sync_error_authentication_failed">Autentimine ei õnnestunud (kontrolli, et kasutajanimi/salasõna oleksid õiged)</string>
|
||||
<string name="sync_error_io">Võrgu- või sisend/väljundviga – %s</string>
|
||||
<string name="sync_error_http_dav">HTTP serveri viga – %s</string>
|
||||
<string name="sync_error_local_storage">Kohaliku salvestusruumi viga – %s</string>
|
||||
<string name="sync_error_retry_limit_reached">Pehme viga (korduspäringute arvu ülempiir on käes)</string>
|
||||
<string name="sync_error_view_item">Vaata objekti</string>
|
||||
<string name="sync_invalid_contact">Saime serverist vigase kontaktikirje</string>
|
||||
<string name="sync_invalid_event">Saime serverist vigase sündmusekirje</string>
|
||||
<string name="sync_invalid_task">Saime serverist vigase ülesandekirje</string>
|
||||
<string name="sync_invalid_resources_ignoring">Eirame ühte või enamat teenust või tarvikut</string>
|
||||
<string name="sync_notification_pending_push_title">Sünkroniseerimine on ootel</string>
|
||||
<string name="sync_notification_pending_push_message">Serveris olevad andmed on muutunud</string>
|
||||
<!--widgets-->
|
||||
<string name="widget_sync_all">Sünkroniseeri kõik</string>
|
||||
<string name="widget_sync_all_accounts">Sünkroniseeri kõik kasutajakontod</string>
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,42 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="app_name">DAVx⁵</string>
|
||||
<string name="account_title_address_book">DAVx⁵ Osoitekirja</string>
|
||||
<string name="address_books_authority_title">Osoitekirjat</string>
|
||||
<string name="help">Apua</string>
|
||||
<string name="manage_accounts">Hallitse tilejä</string>
|
||||
<string name="notification_channel_debugging">Debuggaus</string>
|
||||
<string name="notification_channel_general">Muut tärkeät viestit</string>
|
||||
<string name="notification_channel_sync">Synkronointi</string>
|
||||
<string name="notification_channel_sync_errors">Synkronoinnin virheet</string>
|
||||
<string name="notification_channel_sync_errors_desc">Huomattavat virheet jotka estävät synkronoinnin kuten palvelimen odottamattomat vastaukset </string>
|
||||
<string name="notification_channel_sync_warnings">Synkronoinnin varoitukset</string>
|
||||
<string name="notification_channel_sync_warnings_desc">Ei-kohtalokkaat synkronoinnin ongelmat kuten tietyt virheelliset tiedostot </string>
|
||||
<string name="notification_channel_sync_io_errors">Verkko ja I/O virheet</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">Aikakatkaisut, yhteysvirheet, yms. (usein väliaikaisia)</string>
|
||||
<!--IntroActivity-->
|
||||
<!--PermissionsActivity-->
|
||||
<!--WifiPermissionsActivity-->
|
||||
<!--AboutActivity-->
|
||||
<!--global settings-->
|
||||
<!--AccountsActivity-->
|
||||
<!--DavService-->
|
||||
<!--ForegroundService-->
|
||||
<!--AppSettingsActivity-->
|
||||
<!--AccountActivity-->
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_type_email">Kirjaudu sähköpostilla</string>
|
||||
<string name="login_email_address">Sähköpostiosoite</string>
|
||||
<string name="login_password">Salasana</string>
|
||||
<string name="login_type_url">Kirjaudu verkko-osoitteella ja käyttäjänimellä</string>
|
||||
<string name="login_user_name">Käyttäjänimi</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_username">Käyttäjänimi</string>
|
||||
<string name="settings_password">Salasana</string>
|
||||
<!--collection management-->
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<!--ExceptionInfoFragment-->
|
||||
<!--sync adapters-->
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,418 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">Account inesistente (o cancellato)</string>
|
||||
<string name="account_title_address_book">Rubrica DAVx⁵</string>
|
||||
<string name="dialog_delete">Cancella</string>
|
||||
<string name="dialog_remove">Elimina</string>
|
||||
<string name="dialog_deny">Annulla</string>
|
||||
<string name="field_required">Questo campo è necessario</string>
|
||||
<string name="help">Aiuto</string>
|
||||
<string name="optional_label">* opzionale</string>
|
||||
<string name="options_menu">Menu opzioni</string>
|
||||
<string name="share">Condividi</string>
|
||||
<string name="sync_started">Sincronizzazione avviata</string>
|
||||
<string name="database_destructive_migration_title">Database danneggiato</string>
|
||||
<string name="database_destructive_migration_text">Tutti gli account sono stati rimossi localmente.</string>
|
||||
<string name="notification_channel_debugging">Debugging</string>
|
||||
<string name="notification_channel_general">Altri messaggi importanti</string>
|
||||
<string name="notification_channel_status">Messaggi di stato a bassa priorità</string>
|
||||
<string name="notification_channel_sync">Sincronizzazione</string>
|
||||
<string name="notification_channel_sync_errors">Errori di sincronizzazione</string>
|
||||
<string name="notification_channel_sync_errors_desc">Errori importanti che bloccano la sincronizzazione, come risposte inattese del server</string>
|
||||
<string name="notification_channel_sync_warnings">Avvisi di sincronizzazione</string>
|
||||
<string name="notification_channel_sync_warnings_desc">Problemi di sincronizzazione non gravi come alcuni file non validi</string>
|
||||
<string name="notification_channel_sync_io_errors">Errori di Rete e di I/O</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">Timeouts, problemi di connessione, ecc. (spesso temporanei)</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">Tuoi i dati. Tua la scelta.</string>
|
||||
<string name="intro_slogan2">Riprendi il controllo.</string>
|
||||
<string name="intro_battery_title">Intervalli di sincronizzazione regolari.</string>
|
||||
<string name="intro_battery_text">Per sincronizzare i dati a intervalli regolari, %s deve essere autorizzato a girare in background. Altrimenti Android può mettere in pausa gli aggiornamenti in qualunque momento.</string>
|
||||
<string name="intro_battery_dont_show">Non ho bisogno di sincronizzare a intervalli di tempo regolari.*</string>
|
||||
<string name="intro_autostart_title">%s compatibilità</string>
|
||||
<string name="intro_autostart_text">Questo dispositivo probabilmente impedisce la sincronizzazione. In questo caso puoi risolvere solo manualmente.</string>
|
||||
<string name="intro_autostart_dont_show">Ho settato le impostazioni richieste. Non ricordarmelo più.</string>
|
||||
<string name="intro_leave_unchecked">* Lascia smarcato per fartelo ricordare dopo. Può essere reimpostato nelle impostazione dell\'app %s.</string>
|
||||
<string name="intro_more_info">Maggiori informazioni</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[Supporta la sincronizzazione di Attività, Diari e Note.]]></string>
|
||||
<string name="intro_tasks_title">Supporto per le attività</string>
|
||||
<string name="intro_tasks_text1">Se le attività sono supportate dal tuo server, possono essere sincronizzate con una app per attività supportata:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks</string>
|
||||
<string name="intro_tasks_opentasks_info">Non sembra essere più sviluppato - non raccomandato.</string>
|
||||
<string name="intro_tasks_tasks_org">Tasks.org</string>
|
||||
<string name="intro_tasks_no_app_store">Nessun app store disponibile</string>
|
||||
<string name="intro_tasks_dont_show">Non ho bisogno del supporto alle attività.*</string>
|
||||
<string name="intro_open_source_title">Software open-source</string>
|
||||
<string name="intro_open_source_text">Siamo felici che tu usi %s, che è un software open source. Lo sviluppo, la manutenzione e il supporto sono compiti duri. Per piacere prendi in considerazione di dare una mano (puoi farlo in molti modi) o una donazione. Sarebbe davvero apprezzato!</string>
|
||||
<string name="intro_open_source_details">Come aiutare/donare</string>
|
||||
<string name="intro_open_source_dont_show">Non mostrare nell\'immediato futuro</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_title">Autorizzazioni</string>
|
||||
<string name="permissions_text">%s richiede autorizzazioni per funzionare correttamente.</string>
|
||||
<string name="permissions_all_title">Tutti i seguenti</string>
|
||||
<string name="permissions_all_status_off">Usare questo per abilitare tutte le funzioni (consigliato)</string>
|
||||
<string name="permissions_all_status_on">Concedi tutte le autorizzazioni</string>
|
||||
<string name="permissions_contacts_title">Autorizzazioni per i contatti</string>
|
||||
<string name="permissions_contacts_status_off">Non sincronizzare i contatti (sconsigliato)</string>
|
||||
<string name="permissions_contacts_status_on">Possibilità di sincronizzare i contatti</string>
|
||||
<string name="permissions_calendar_title">Autorizzazioni per il calendario</string>
|
||||
<string name="permissions_calendar_status_off">Non sincronizzare il calendario (sconsigliato)</string>
|
||||
<string name="permissions_calendar_status_on">Permette di sincronizzare il calendario</string>
|
||||
<string name="permissions_notification_title">Autorizza notifiche</string>
|
||||
<string name="permissions_notification_status_off">Notifiche disabilitate (non consigliato)</string>
|
||||
<string name="permissions_notification_status_on">Notifiche attive</string>
|
||||
<string name="permissions_opentasks_title">Autorizzazioni di OpenTasks</string>
|
||||
<string name="permissions_tasksorg_title">Autorizzazioni delle attività</string>
|
||||
<string name="permissions_tasks_status_on">Permette di sincronizzare le attività</string>
|
||||
<string name="permissions_autoreset_title">Mantieni autorizzazioni</string>
|
||||
<string name="permissions_autoreset_status_off">Le autorizzazioni possono essere reimpostate automaticamente (sconsigliato)</string>
|
||||
<string name="permissions_autoreset_status_on">Le autorizzazioni non si reimposteranno automaticamente</string>
|
||||
<string name="permissions_autoreset_instruction">Fai click su Autorizzazioni > deseleziona \"Rimuovi autorizzazioni se l\'app non è in uso\"</string>
|
||||
<string name="permissions_app_settings_hint">Se uno slider non funziona, vai a impostazioni app/ autorizzazioni.</string>
|
||||
<string name="permissions_app_settings">Impostazioni app</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<string name="wifi_permissions_label">Autorizzazioni per WiFi SSID</string>
|
||||
<string name="wifi_permissions_intro">Per poter accedere al nome dell\'attuale nome del WIFI (SSID), devono essere soddfsfatte queste condizioni:</string>
|
||||
<string name="wifi_permissions_location_permission">Autorizzazione precisa della localizzazione</string>
|
||||
<string name="wifi_permissions_location_permission_on">Garantire l\'autorizzazione della posizione</string>
|
||||
<string name="wifi_permissions_location_permission_off">Negare l\'autorizzazione della posizione</string>
|
||||
<string name="wifi_permissions_background_location_permission">Autorizzazione della posizione in background</string>
|
||||
<string name="wifi_permissions_background_location_permission_label">Permettere sempre</string>
|
||||
<string name="wifi_permissions_background_location_permission_on">Permessi di localizzazione impostati a: %s</string>
|
||||
<string name="wifi_permissions_background_location_permission_off">Permessi di localizzazione non impostati a: %s</string>
|
||||
<string name="wifi_permissions_location_enabled">Posizione sempre disabilitata</string>
|
||||
<string name="wifi_permissions_location_enabled_on">Servizio di posizione abiltato</string>
|
||||
<string name="wifi_permissions_location_enabled_off">Servizio di posizione disabilitato</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_translations">Traduzioni</string>
|
||||
<string name="about_libraries">Librerie</string>
|
||||
<string name="about_version">Versione %1$s (%2$d)</string>
|
||||
<string name="about_copyright">© Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e contibutori</string>
|
||||
<string name="about_license_info_no_warranty">Il programma è distribuito SENZA ALCUNA GARANZIA. È software libero e può essere redistribuito sotto alcune condizioni.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">Impossibile creare il file di log</string>
|
||||
<string name="logging_notification_text">Adesso l\'accesso all\' %s delle attività </string>
|
||||
<string name="logging_notification_view_share">Visualizza/condividi</string>
|
||||
<string name="logging_notification_disable">Disabilita</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV adattatore di sincronizzazione</string>
|
||||
<string name="navigation_drawer_about">Informazioni / Licenza</string>
|
||||
<string name="navigation_drawer_beta_feedback">Feedback sulla beta</string>
|
||||
<string name="install_browser">Installare un browser Web</string>
|
||||
<string name="navigation_drawer_settings">Impostazioni</string>
|
||||
<string name="navigation_drawer_news_updates">Notizie & aggiornamenti</string>
|
||||
<string name="navigation_drawer_tools">Strumenti</string>
|
||||
<string name="navigation_drawer_external_links">Link esterni</string>
|
||||
<string name="navigation_drawer_website">Sito web</string>
|
||||
<string name="navigation_drawer_manual">Manuale</string>
|
||||
<string name="navigation_drawer_faq">Domande Frequenti</string>
|
||||
<string name="navigation_drawer_community">Comunità</string>
|
||||
<string name="navigation_drawer_support_project">Supporta il progetto</string>
|
||||
<string name="navigation_drawer_contribute">Come contribuire</string>
|
||||
<string name="navigation_drawer_privacy_policy">Politica sulla riservatezza</string>
|
||||
<string name="account_list_no_notification_permission">Notifiche non attive. Non sarai avvisato di eventuali errori di sincronizzazione</string>
|
||||
<string name="account_list_manage_connections">Gestione connessioni</string>
|
||||
<string name="account_list_datasaver_enabled">Risparmio dati attivo. La sincronizzazione in background è limitata,</string>
|
||||
<string name="account_list_battery_saver_enabled">Risparmio energetico attivo. La sincronizzazione in background è limitata,</string>
|
||||
<string name="account_list_manage_battery_saver">Gestisci risparmio energetico</string>
|
||||
<string name="account_list_low_storage">Spazio di memorizzazione scarso. Androin non salverà immediatamente i cambiamente, ma alla prossima sincronizzazione programmata.</string>
|
||||
<string name="account_list_manage_storage">Gestisci spazio di memorizzazione</string>
|
||||
<string name="accounts_sync_all">Sincronizzazione di tutti gli account</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">Impossibile trovare il servizio</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">Impossibile aggiornare la lista delle raccolte</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<string name="foreground_service_notify_title">Esecuzione in primo piano</string>
|
||||
<string name="foreground_service_notify_text">Su alcuni dispositivi, questo è necessario per la sincronizzazione automatica.</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Impostazioni</string>
|
||||
<string name="app_settings_debug">Debug</string>
|
||||
<string name="app_settings_show_debug_info">Mostra informazioni di debug</string>
|
||||
<string name="app_settings_logging">Log completo</string>
|
||||
<string name="app_settings_logging_off">Log disabilitato</string>
|
||||
<string name="app_settings_battery_optimization">Ottimizzazione batteria</string>
|
||||
<string name="app_settings_connection">Connessione</string>
|
||||
<string name="app_settings_proxy">Tipo di proxy</string>
|
||||
<string-array name="app_settings_proxy_types">
|
||||
<item>Predefinito di sistema</item>
|
||||
<item>Nessun proxy</item>
|
||||
<item>HTTP</item>
|
||||
<item>SOCKS (per Orbot)</item>
|
||||
</string-array>
|
||||
<string name="app_settings_proxy_host">Nome host proxy</string>
|
||||
<string name="app_settings_proxy_port">Porta proxy</string>
|
||||
<string name="app_settings_security">Sicurezza</string>
|
||||
<string name="app_settings_security_app_permissions">Autorizzazioni app</string>
|
||||
<string name="app_settings_security_app_permissions_summary">Controlla le autorizzazioni per la sincronizzazione</string>
|
||||
<string name="app_settings_distrust_system_certs">Non ti fidare dei certificati di sistema</string>
|
||||
<string name="app_settings_distrust_system_certs_on">Le CA di sistema e quelle aggiunte dall\'utente non sono affidabili</string>
|
||||
<string name="app_settings_distrust_system_certs_off">Le CA di sistema e quelle aggiunte dall\'utente sono affidabili (raccomandato)</string>
|
||||
<string name="app_settings_reset_certificates">Reimposta la fiducia in tutti i certificati</string>
|
||||
<string name="app_settings_reset_certificates_summary">Reimposta la fiducia nei certificati aggiunti</string>
|
||||
<string name="app_settings_reset_certificates_success">Sono stati cancellati tutti i certificati aggiunti</string>
|
||||
<string name="app_settings_user_interface">Interfaccia utente</string>
|
||||
<string name="app_settings_notification_settings">Impostazioni di notifica</string>
|
||||
<string name="app_settings_notification_settings_summary">Gestisci i canali di notifica e le loro impostazioni</string>
|
||||
<string name="app_settings_theme_title">Seleziona il tema</string>
|
||||
<string-array name="app_settings_theme_names">
|
||||
<item> Sistema predefinito </item>
|
||||
<item> Luce </item>
|
||||
<item> Buio </item>
|
||||
</string-array>
|
||||
<string name="app_settings_reset_hints">Reimposta i suggerimenti</string>
|
||||
<string name="app_settings_reset_hints_summary">Riabilita i suggerimenti precedentemente disabilitati</string>
|
||||
<string name="app_settings_reset_hints_success">I suggerimenti verranno mostrati</string>
|
||||
<string name="app_settings_integration">Integrazione</string>
|
||||
<string name="app_settings_tasks_provider">Funzioni dell\'applicazione</string>
|
||||
<string name="app_settings_tasks_provider_none">Nessuna applicazione compatibile con e funzionalità trovata</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_missing_permissions">Per sincronizzare questi dati sono richiesti permessi aggiuntivi.</string>
|
||||
<string name="account_manage_permissions">Gestisci permessi</string>
|
||||
<string name="account_synchronize_now">Sincronizza adesso</string>
|
||||
<string name="account_settings">Impostazioni account</string>
|
||||
<string name="account_rename">Rinomina account</string>
|
||||
<string name="account_rename_new_name_description">Dati locali non salvati potrebbero venir persi. Dopo il cambio nome è necessaria la ri-sincronizzazione.</string>
|
||||
<string name="account_rename_new_name">Nuovo nome account</string>
|
||||
<string name="account_rename_rename">Rinomina</string>
|
||||
<string name="account_rename_exists_already">Nome account già usato</string>
|
||||
<string name="account_rename_couldnt_rename">Impossibile rinominare l\'account</string>
|
||||
<string name="account_delete">Elimina account</string>
|
||||
<string name="account_delete_confirmation_title">Cancellare l\'account?</string>
|
||||
<string name="account_delete_confirmation_text">Tutte le copie locali delle rubriche, dei calendari e degli elenchi attività verranno eliminate.</string>
|
||||
<string name="account_synchronize_this_collection">Sincronizza questa raccolta</string>
|
||||
<string name="account_read_only">sola lettura</string>
|
||||
<string name="account_calendar">calendario</string>
|
||||
<string name="account_contacts">contatti</string>
|
||||
<string name="account_journal">diario</string>
|
||||
<string name="account_task_list">attività</string>
|
||||
<string name="account_only_personal">Mostra solo personale</string>
|
||||
<string name="account_refresh_collections">Aggiorna lista</string>
|
||||
<string name="account_webcal_external_app">Sottoscrizioni al Webcal possono essere sincronizzate con applicazioni esterne.</string>
|
||||
<string name="account_no_webcal_handler_found">Non ho trovato nessuna applicazione abilitata per Webcal</string>
|
||||
<string name="account_install_icsx5">Installa ICSx⁵</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Aggiungi account</string>
|
||||
<string name="login_generic_login">Login generico</string>
|
||||
<string name="login_provider_login">Login del Provider</string>
|
||||
<string name="login_continue">Continua</string>
|
||||
<string name="login_login">Login</string>
|
||||
<string name="login_type_email">Accedi con indirizzo email</string>
|
||||
<string name="login_email_address">Indirizzo email</string>
|
||||
<string name="login_email_address_error">È necessario un indirizzo email valido</string>
|
||||
<string name="login_email_address_info"><![CDATA[Viene usato il dominio dell\'email come URL base. <a href="%s">I servizi sono individuati </a> usando record DNS e le URL well-known.]]></string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_password_hide">Nascondi password</string>
|
||||
<string name="login_password_show">Mostra password</string>
|
||||
<string name="login_password_optional">Password*</string>
|
||||
<string name="login_type_url">Accedi con URL e nome utente</string>
|
||||
<string name="login_user_name">Nome utente</string>
|
||||
<string name="login_user_name_optional">Nome utente*</string>
|
||||
<string name="login_base_url">Base URL</string>
|
||||
<string name="login_base_url_info"><![CDATA[La URL base viene controllata direttamente, ma <a href="%s">i servizi sono individuati anche </a> usando record DNS records e le URL well-known.]]></string>
|
||||
<string name="login_select_certificate">Seleziona certificato</string>
|
||||
<string name="login_add_account">Aggiungi account</string>
|
||||
<string name="login_account_name">Nome account</string>
|
||||
<string name="login_account_avoid_apostrophe">L\'uso degli apostrofi (\') potrebbe causare problemi su alcuni dispositivi.</string>
|
||||
<string name="login_account_name_info">Inserisci il tuo indirizzo email come nome dell\'account in quanto Android userà il nome dell\'account nel campo ORGANIZER degli eventi creati. Non è possibile avere due account con nome uguale.</string>
|
||||
<string name="login_account_contact_group_method">Metodo del contact group:</string>
|
||||
<string name="login_account_name_required">Richiesto il nome dell\'account</string>
|
||||
<string name="login_account_name_already_taken">Nome account già usato</string>
|
||||
<string name="login_type_advanced">Login avanzato</string>
|
||||
<string name="login_no_client_certificate_optional">Nessun certificato client*</string>
|
||||
<string name="login_client_certificate_selected">Certificato client: %s</string>
|
||||
<string name="login_no_certificate_found">Nessun certificato trovato</string>
|
||||
<string name="login_install_certificate">Installa il certificato</string>
|
||||
<string name="login_type_google">Contatti Google / Calendario</string>
|
||||
<string name="login_google_see_tested_with">Consultare la nostra pagina \"Tested with Google\" per informazioni aggiornate.</string>
|
||||
<string name="login_google_account">Account Google</string>
|
||||
<string name="login_google">Accedi con Google</string>
|
||||
<string name="login_google_client_id">ID Client (facoltativo)</string>
|
||||
<string name="login_google_client_limited_use"><![CDATA[%1$s è conforme alla <a href="%2$s">Google API Services User Data Policy</a>, incluso il Limited Use requirements.]]></string>
|
||||
<string name="login_oauth_couldnt_obtain_auth_code">Non posso ottenere il codice di autorizzazione</string>
|
||||
<string name="login_type_nextcloud">Nextcloud</string>
|
||||
<string name="login_nextcloud_login_with_nextcloud">Accedi con Nextcloud</string>
|
||||
<string name="login_nextcloud_login_flow_text">Questo aprirà la pagina di login di Nextcloud nel browser.</string>
|
||||
<string name="login_nextcloud_login_flow_server_address">Indirizzo del server Nextcloud</string>
|
||||
<string name="login_nextcloud_login_flow_sign_in">Iscriviti</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_url">Non posso ottenere l\'URL di login</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_data">Non posso ottenere i dati di login</string>
|
||||
<string name="login_configuration_detection">Rilevazione configurazione</string>
|
||||
<string name="login_querying_server">Attendere, invio richiesta al server…</string>
|
||||
<string name="login_no_service">Impossibile trovare servizi CalDAV o CardDAV.</string>
|
||||
<string name="login_no_service_info">L\'URL base non sembra essere un URL CalDAV/CardDAV accessibile e i servizi di individuazione hanno fallito.</string>
|
||||
<string name="login_check_credentials">Controlla attentamente i dati di autenticazione (normalmente username e password).</string>
|
||||
<string name="login_logs_available">Informazioni tecniche aggiuntive sono reperibili nei log.</string>
|
||||
<string name="login_view_logs">Vedi i registri</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">Sincronizzazione</string>
|
||||
<string name="settings_sync_interval_contacts">Intervallo sincr. Contatti</string>
|
||||
<string name="settings_sync_summary_manually">Solo manualmente</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Ogni %d minuti e a seguito di ogni cambiamento locale</string>
|
||||
<string name="settings_sync_interval_calendars">Intervallo sincr. calendari</string>
|
||||
<string name="settings_sync_interval_tasks">Intervallo sincr. attività</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>Solo manualmente</item>
|
||||
<item>Ogni 15 minuti</item>
|
||||
<item>Ogni 30 minuti</item>
|
||||
<item>Ogni ora</item>
|
||||
<item>Ogni 2 ore</item>
|
||||
<item>Ogni 4 ore</item>
|
||||
<item>Una volta al giorno</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">Sincr. solo tramite WiFi</string>
|
||||
<string name="settings_sync_wifi_only_on">La sincronizzazione è limitata alle connessioni WiFi</string>
|
||||
<string name="settings_sync_wifi_only_off">Il tipo di connessione non è preso in considerazione</string>
|
||||
<string name="settings_sync_wifi_only_ssids">Restrizione SSID WiFi</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Sincronizzeremo solo oltre %s</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Verranno utilizzate tutte le connessioni WIFI </string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">Nomi (SSID) delle reti WiFi autorizzate separati da virgola (lascia vuoto per autorizzarle tutte)</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_required">Le restrizioni del SSID WIFI richiedono ulteriori impostazioni</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">Riuscire</string>
|
||||
<string name="settings_ignore_vpns">La VPN richiede connessione internet</string>
|
||||
<string name="settings_ignore_vpns_on">La VPN senza una connessione internet validata non è sufficiente per lanciare la sincronizzazione (raccomandato)</string>
|
||||
<string name="settings_ignore_vpns_off">La VPN senza una connessione internet validata è sufficiente per lanciare la sincronizzazione</string>
|
||||
<string name="settings_authentication">Autenticazione</string>
|
||||
<string name="settings_username">Nome utente</string>
|
||||
<string name="settings_password">Password</string>
|
||||
<string name="settings_new_password">Nuova password</string>
|
||||
<string name="settings_password_summary">Aggiorna la password come sul tuo server.</string>
|
||||
<string name="settings_certificate_alias">Certificato client</string>
|
||||
<string name="settings_certificate_alias_empty">Nessun certificato disponibile o selezionato</string>
|
||||
<string name="settings_certificate_install">Installa il certificato</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Limite di tempo per gli eventi trascorsi</string>
|
||||
<string name="settings_sync_time_range_past_none">Verranno sincronizzati tutti gli eventi</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Eventi più vecchi di un giorno saranno ignorati</item>
|
||||
<item quantity="many">Eventi più vecchi di %d giorni saranno ignorati</item>
|
||||
<item quantity="other">Eventi più vecchi di %d giorni saranno ignorati</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Eventi più vecchi di questo numero di giorni verranno ignorati(può anche essere 0). Lasciare in bianco per sincronizzare tutti gli eventi.</string>
|
||||
<string name="settings_default_alarm">Promemoria predefinito</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="one">Promemoria predefinito un minuto prima dell\'evento</item>
|
||||
<item quantity="many">Promemoria predefinito %d minuti prima dell\'evento</item>
|
||||
<item quantity="other">Promemoria predefinito %d minuti prima dell\'evento</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">Nessun promemoria di default creato</string>
|
||||
<string name="settings_default_alarm_message">Indicare il numero di minuti che si desidera per il promemoria predefinito.
|
||||
Lasciare vuoto per non creare un promemoria predefinito.</string>
|
||||
<string name="settings_manage_calendar_colors">Cambia il colore del calendario</string>
|
||||
<string name="settings_manage_calendar_colors_on">I colori del calendario sono resettati ad ogni sincronizzazione</string>
|
||||
<string name="settings_manage_calendar_colors_off">I colori del calendario possono essere scelti da altre applicazioni</string>
|
||||
<string name="settings_event_colors">Supporto colore dell\'evento</string>
|
||||
<string name="settings_event_colors_on">I colori degli eventi sono sincronizzati</string>
|
||||
<string name="settings_event_colors_off">I colori degli eventi non sono sicnronizzati</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Organizzazione dei gruppi di contatto</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>I gruppi sono vCards separate</item>
|
||||
<item>I gruppi sono categorie per ogni contatto</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">Crea rubrica</string>
|
||||
<string name="create_addressbook_maybe_not_supported">La creazione di rubriche tramitte CardDAV potrebbe non essere supportata dal server.</string>
|
||||
<string name="create_calendar">Crea calendario</string>
|
||||
<string name="create_calendar_time_zone_optional">Fuso orario di default*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">Possibili voci del calendario</string>
|
||||
<string name="create_calendar_type_vevent">Eventi</string>
|
||||
<string name="create_calendar_type_vtodo">Attività</string>
|
||||
<string name="create_calendar_type_vjournal">Note / diario</string>
|
||||
<string name="create_calendar_maybe_not_supported">La creazione do calendari tramite CalDAV potrebbe non essere supportata dal server.</string>
|
||||
<string name="create_collection_color">Colore</string>
|
||||
<string name="create_collection_display_name">Titolo</string>
|
||||
<string name="create_collection_home_set">Percorso di archiviazione</string>
|
||||
<string name="create_collection_description_optional">Descrizione*</string>
|
||||
<string name="create_collection_create">Crea</string>
|
||||
<string name="create_collection_optional">* opzionale</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">Elimina raccolta</string>
|
||||
<string name="collection_delete_warning">Questa raccolta (%s) e tutti i suoi dati saranno rimossi definitivamente, sia localmente che sul server.</string>
|
||||
<string name="collection_synchronization">Sincronizzazione</string>
|
||||
<string name="collection_synchronization_on">Sincronizzazione attivata</string>
|
||||
<string name="collection_synchronization_off">Sincronizzazione disattivata</string>
|
||||
<string name="collection_read_only">Sola lettura</string>
|
||||
<string name="collection_read_only_by_server">Sola lettura (dal server)</string>
|
||||
<string name="collection_read_only_forced">Sola lettura (locale)</string>
|
||||
<string name="collection_read_write">Lettura/scrittura</string>
|
||||
<string name="collection_title">Titolo</string>
|
||||
<string name="collection_description">Descrizione</string>
|
||||
<string name="collection_owner">Proprietario</string>
|
||||
<string name="collection_push_support">Supporto push</string>
|
||||
<string name="collection_last_sync">Ultima sincronizzazione %s</string>
|
||||
<string name="collection_url">Indirizzo (URL)</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Informazioni di debug</string>
|
||||
<string name="debug_info_archive_caption">Archivio ZIP</string>
|
||||
<string name="debug_info_archive_subtitle">Contiene informazioni sui debug e sugli accessi</string>
|
||||
<string name="debug_info_archive_text">Condividi l\'archivio per trasferirlo ad un computer, per inviarlo tramite email o per fissarlo ad un ticket di supporto.</string>
|
||||
<string name="debug_info_archive_share">Condividi l\'archivio</string>
|
||||
<string name="debug_info_attached">Informazioni sul debug fissate a questo messaggio (richiede un supporto di fissaggio dell\'applicazione di supporto). </string>
|
||||
<string name="debug_info_http_error">Errore HTTP</string>
|
||||
<string name="debug_info_server_error">Errore del Server</string>
|
||||
<string name="debug_info_webdav_error">Errore WebDAV</string>
|
||||
<string name="debug_info_io_error">Errore I/O</string>
|
||||
<string name="debug_info_http_403_description">La richiesta è stata negata. Controlla le fonti coinvolte e le informazioni debug per dettagli.</string>
|
||||
<string name="debug_info_http_404_description">La fonte richiesta non esiste (più). Controlla le fonti coinvolte e le informazioni debug per dettagli.</string>
|
||||
<string name="debug_info_http_5xx_description">Si è verificato un problema del server. Per favore contatta il tuo server di supporto.</string>
|
||||
<string name="debug_info_unexpected_error">Si è verificato un errore inaspettato. Vedi le informazioni di debug per maggiori dettagli.</string>
|
||||
<string name="debug_info_view_details">Vedi dettagli</string>
|
||||
<string name="debug_info_subtitle">Sono state raccolte informazioni di debug</string>
|
||||
<string name="debug_info_involved_caption">Fonti coinvolte</string>
|
||||
<string name="debug_info_involved_subtitle">Collegate con il problema</string>
|
||||
<string name="debug_info_involved_remote">Fonti remote:</string>
|
||||
<string name="debug_info_involved_local">Fonti locali:</string>
|
||||
<string name="debug_info_logs_caption">Registri</string>
|
||||
<string name="debug_info_logs_subtitle">Sono disponibili registri verbali</string>
|
||||
<string name="debug_info_logs_view">Vedi i registri</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Si è verificato un errore.</string>
|
||||
<string name="exception_httpexception">Si è verificato un errore HTTP.</string>
|
||||
<string name="exception_ioexception">Si è verificato un errore di I/O.</string>
|
||||
<string name="exception_show_details">Mostra dettagli</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_mounts_title">Installazioni WebDAV</string>
|
||||
<string name="webdav_mounts_quota_used_available">Quantità utilizzata: %1$s / disponibile: %2$s</string>
|
||||
<string name="webdav_mounts_share_content">Condividi i contenuti</string>
|
||||
<string name="webdav_mounts_unmount">Disinstallazioni</string>
|
||||
<string name="webdav_add_mount_title">Aggiungi installazioni WedDAV</string>
|
||||
<string name="webdav_mounts_empty">Accedi direttamente ai tuoi file nel cloud aggiungendo un supporto WebDAV!</string>
|
||||
<string name="webdav_add_mount_empty_more_info"><![CDATA[Leggi il manuale per vedere <a href="%1$s">come funzionano i supporti WebDAV</a>.</string>]]></string>
|
||||
<string name="webdav_add_mount_display_name">Nome del display</string>
|
||||
<string name="webdav_add_mount_url">URL WebDVA</string>
|
||||
<string name="webdav_add_mount_url_invalid">URL non valido</string>
|
||||
<string name="webdav_add_mount_authentication">Autenticazione (facoltativa)</string>
|
||||
<string name="webdav_add_mount_username">Nome utente</string>
|
||||
<string name="webdav_add_mount_password">Password</string>
|
||||
<string name="webdav_add_mount_add">Aggiungi installazioni</string>
|
||||
<string name="webdav_add_mount_no_support">Nessun servizio WebDAV a questo URL</string>
|
||||
<string name="webdav_remove_mount_title">Rimuovi punto di mont</string>
|
||||
<string name="webdav_remove_mount_text">I dettagli della connessione saranno perduti, ma nessun file verrà cancellato.</string>
|
||||
<string name="webdav_notification_access">File di accesso WebDAV</string>
|
||||
<string name="webdav_notification_download">File di download WebDAV</string>
|
||||
<string name="webdav_notification_upload">Caricare file WebDAV</string>
|
||||
<string name="webdav_provider_root_title">Installazione WebDAV</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">Autorizzazioni DAVx⁵</string>
|
||||
<string name="sync_error_permissions_text">Autorizzazioni addizionali richieste</string>
|
||||
<string name="sync_error_tasks_too_old">%s troppo vecchio</string>
|
||||
<string name="sync_error_tasks_required_version">Versione minima richiesta %1$s</string>
|
||||
<string name="sync_error_authentication_failed">Autenticazione fallita (controlla credenziali login)</string>
|
||||
<string name="sync_error_io">Errore di rete o di I/O – %s</string>
|
||||
<string name="sync_error_http_dav">Errore server HTTP – %s</string>
|
||||
<string name="sync_error_local_storage">Errore di archiviazione locale – %s</string>
|
||||
<string name="sync_error_view_item">Visualizza oggetto</string>
|
||||
<string name="sync_invalid_contact">Contatto non valido ricevuto dal server</string>
|
||||
<string name="sync_invalid_event">Evento non valido ricevuto dal server</string>
|
||||
<string name="sync_invalid_task">Attività non valida ricevuta dal server</string>
|
||||
<string name="sync_invalid_resources_ignoring">Una o più risorse non valide ignorate</string>
|
||||
<!--widgets-->
|
||||
<string name="widget_sync_all">Sincronizza tutto</string>
|
||||
<string name="widget_sync_all_accounts">Sincronizzazione di tutti gli account</string>
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,427 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">ანგარიში (აღარ) არსებობს</string>
|
||||
<string name="account_title_address_book">DAVx⁵ მისამართთა წიგნაკი</string>
|
||||
<string name="dialog_delete">წაშლა</string>
|
||||
<string name="dialog_remove">ამოშლა</string>
|
||||
<string name="dialog_deny">გაუქმება</string>
|
||||
<string name="field_required">ეს ველი სავალდებულოა</string>
|
||||
<string name="help">დახმარება</string>
|
||||
<string name="navigate_up">ზემოთ გადასვლა</string>
|
||||
<string name="optional_label">* არასავალდებულო</string>
|
||||
<string name="options_menu">ოპციების მენიუ</string>
|
||||
<string name="share">გაზიარება</string>
|
||||
<string name="sync_started">სინქრონიზაცია დაიწყა/დადგა რიგში</string>
|
||||
<string name="database_destructive_migration_title">მონაცემთა ბაზა კორუმპირებულია</string>
|
||||
<string name="database_destructive_migration_text">ყველა ანგარიში წაშლილ იქნა ადგილობრივად.</string>
|
||||
<string name="notification_channel_debugging">დებაგი</string>
|
||||
<string name="notification_channel_general">სხვა მნიშვნელოვანი შეტყობინებები</string>
|
||||
<string name="notification_channel_status">დაბალი პრიორიტეტის სტატუსის შეტყობინებები</string>
|
||||
<string name="notification_channel_sync">სინქრონიზაცია</string>
|
||||
<string name="notification_channel_sync_errors">სინქრონიზაციის შეცდომები</string>
|
||||
<string name="notification_channel_sync_errors_desc">მნიშვნელოვანი შეცდომები, რომლებიც აჩერებს სინქრონიზაციას, მაგ., მოულოდნელი სერვერის პასუხები</string>
|
||||
<string name="notification_channel_sync_warnings">სინქრონიზაციის გაფრთხილებები</string>
|
||||
<string name="notification_channel_sync_warnings_desc">არა-ლეტალური სინქრონიზაციის პრობლემები, როგორც ზოგი არასწორი ფაილი</string>
|
||||
<string name="notification_channel_sync_io_errors">ქსელის ან ჩაწერა/წაკითხვის შეცდომები</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">ვადის გასვლა, კავშირის პრობლემები, სხვა (ხშირად დროებითი)</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">თქვენი მონაცემები. თქვენი არჩევანი.</string>
|
||||
<string name="intro_slogan2">აიღეთ კონტროლი.</string>
|
||||
<string name="intro_battery_title">რეგულარული სინქრონიზაციის ინტერვალები</string>
|
||||
<string name="intro_battery_text">რეგულარული ინტერვალი სინქრონიზაციისთვის, %s-ს უნდა ჰქონდეს უფლება გაეშვას ფონურ რეჟიმში. სხვაგვარად, Android-მა შეიძლება ნებისმიერ მომენტში შეაჩეროს სინქრონიზაცია.</string>
|
||||
<string name="intro_battery_dont_show">მე არ მჭირდება რეგულარული სინქრონიზაციის ინტერვალები.*</string>
|
||||
<string name="intro_autostart_title">%s თავსებადობა</string>
|
||||
<string name="intro_autostart_text">ეს მოწყობილობა სავარაუდოდ ბლოკავს სინქრონიზაცია. თუ ეს გეხებათ, ამის გამოსწორება მხოლოდ ხელით შეიძლება.</string>
|
||||
<string name="intro_autostart_dont_show">მე შევცვალე საჭირო პარამეტრები. აღარ შემახსენოთ.*</string>
|
||||
<string name="intro_leave_unchecked">* დატოვეთ მოუნიშნელად მოგვიანებით შესახსენებლად. შეიძლება ჩამოგდებულ იქნას აპის პარამეტრებში /%s.</string>
|
||||
<string name="intro_more_info">მეტი ინფორმაცია</string>
|
||||
<string name="intro_tasks_jtx">jtx Board</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[Supports sync of Tasks, Journals and Notes.]]></string>
|
||||
<string name="intro_tasks_title">დავალებების მხარდაჭერა</string>
|
||||
<string name="intro_tasks_text1">თუ დავალებები მხარდაჭერილია თქვენი სერვერის მიერ, მათი სინქრონიზირება შეიძლება მხარდაჭერილი დავალებათა აპით:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks</string>
|
||||
<string name="intro_tasks_opentasks_info">აღარ მიმდინარეობს განვითარება - არ არის რეკომენდებული.</string>
|
||||
<string name="intro_tasks_tasks_org">Tasks.org</string>
|
||||
<string name="intro_tasks_no_app_store">აპების მაღაზია ხელმიუწვდომია</string>
|
||||
<string name="intro_tasks_dont_show">მე არ მჭირდება დავალებების მხარდაჭერა.*</string>
|
||||
<string name="intro_open_source_title">ღია კოდის პროგრამული უზრუნველყოფა</string>
|
||||
<string name="intro_open_source_text">კმაყოფილები ვართ, რომ იყენებთ %s-ს, რომელიც ღია კოდის პროგრამული უზრუნველყოფაა. განვითარება და მხარდაჭერა რთული სამუშაო. გთხოვთ, გაითვალისწინოთ წილის შეტანა (მრავალი გზა არსებობს) ან ფულის ჩუქბეა. ძალიან მადლობელი ვიქნებით!</string>
|
||||
<string name="intro_open_source_details">როგორ შევიტანო წვლილი/დაგეხმაროთ</string>
|
||||
<string name="intro_open_source_dont_show">არ მაჩვენოთ ახლო მომავალში</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_title">უფლებები</string>
|
||||
<string name="permissions_text">%s-ს სჭირდება უფლებები სწორად სამუშაოდ.</string>
|
||||
<string name="permissions_all_title">ყველა ქვემოთ მოცემული</string>
|
||||
<string name="permissions_all_status_off">გამოიყენეთ ეს ყველა ფუნქციის ჩასართავად (რეკომენდებული)</string>
|
||||
<string name="permissions_all_status_on">ყველა უფლება დართულია</string>
|
||||
<string name="permissions_contacts_title">კონტაქტების უფლებები</string>
|
||||
<string name="permissions_contacts_status_off">კონტაქტის სინქრონიზაციის გარეშე (არა რეკომენდებული)</string>
|
||||
<string name="permissions_contacts_status_on">კონტაქტის სინქრონიზაცია შესაძლებელია</string>
|
||||
<string name="permissions_calendar_title">კალენდარის უფლებები</string>
|
||||
<string name="permissions_calendar_status_off">კალენდარის სინქრონიზაციის გარეშე (არა რეკომენდებული)</string>
|
||||
<string name="permissions_calendar_status_on">კალენდარის სინქრონიზაცია შესაძლებელია</string>
|
||||
<string name="permissions_notification_title">შეტყობინებების უფლება</string>
|
||||
<string name="permissions_notification_status_off">შეტყობინებები გათიშულია (არა რეკომენდებული)</string>
|
||||
<string name="permissions_notification_status_on">შეტყობინებები ჩართლია</string>
|
||||
<string name="permissions_jtx_title">jtx Board-ის უფლებები</string>
|
||||
<string name="permissions_opentasks_title">OpenTasks-ის უფლებები</string>
|
||||
<string name="permissions_tasksorg_title">დავალებების უფლებები</string>
|
||||
<string name="permissions_tasks_status_off">დავალებების სინქრონიზაციის გარეშე</string>
|
||||
<string name="permissions_tasks_status_on">დავალებების სინქრონიზაცია შესაძლებელია</string>
|
||||
<string name="permissions_autoreset_title">Keep-ის უფლებები</string>
|
||||
<string name="permissions_autoreset_status_off">უფლებები შეიძლება ავტომატურად ჩამოიყაროს (არა რეკომენდებული)</string>
|
||||
<string name="permissions_autoreset_status_on">უფლებები ავტომატურად არ ჩამოიყრება</string>
|
||||
<string name="permissions_autoreset_instruction">შეამოწმეთ უფლებები > მოხსენით \"უფლებების ამოშლა, თუ აპი არ გამოიყენება\"-ს მონიშვნა</string>
|
||||
<string name="permissions_app_settings_hint">თუ გადამრთველი არ მუშაობს, გამოიყენეთ აპის პარამეტრები / უფლებები.</string>
|
||||
<string name="permissions_app_settings">აპის პარამეტრები</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<string name="wifi_permissions_label">WiFi SSID-ს უფლებები</string>
|
||||
<string name="wifi_permissions_intro">რათა მიწვდეთ მიმდინარე WiFi-ს სახელს (SSID), ეს პირობები უნდა შესრულდეს:</string>
|
||||
<string name="wifi_permissions_location_permission">ზუსტი ადგილმდებარეობის უფლება</string>
|
||||
<string name="wifi_permissions_location_permission_on">ადგილმდებარეობის უფლება დართულია</string>
|
||||
<string name="wifi_permissions_location_permission_off">ადგილმდებარეობის უფლება უარყოფილია</string>
|
||||
<string name="wifi_permissions_background_location_permission">ფონური ადგილმდებარეობის უფლება</string>
|
||||
<string name="wifi_permissions_background_location_permission_label">ყოველთვის დაშვება</string>
|
||||
<string name="wifi_permissions_background_location_permission_on">ადგილმდებარეობის უფლების მნიშვნელობა: %s</string>
|
||||
<string name="wifi_permissions_background_location_permission_off">ადგილმდებარეობის უფლება არ არის შემდეგი: %s</string>
|
||||
<string name="wifi_permissions_location_enabled">ადგილმდებარეობა ყოველთვის ჩართულია</string>
|
||||
<string name="wifi_permissions_location_enabled_on">ადგილმდებარეობის სერვისი ჩართულია</string>
|
||||
<string name="wifi_permissions_location_enabled_off">ადგილმდებარეობის სერვისი გათიშულია</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_translations">თარგმანი</string>
|
||||
<string name="about_libraries">ბიბლიოთეკები</string>
|
||||
<string name="about_version">ვერსია %1$s (%2$d)</string>
|
||||
<string name="about_copyright">© Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) და მონაწილეები</string>
|
||||
<string name="about_license_info_no_warranty">ამ პროგრამას არ აქვს არანაირი გარანტია. იგი არის უფასო პროგრამული უზრუნველყოფა, ხოლო თქვენ შეგეძლეიათ იგი გაავრცელოთ გარკვეული პირობების გათვალისწინებით.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">ჟურნალის ფაილი ვერ შეიქმნა</string>
|
||||
<string name="logging_notification_text">აწი მიმდინარეობს ყველა %s აქტივობის ჟურნალში ჩაწერა</string>
|
||||
<string name="logging_notification_view_share">ნახვა/გაზიარება</string>
|
||||
<string name="logging_notification_disable">გათიშვა</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV სინქრონიზაციის ადაპტერი</string>
|
||||
<string name="navigation_drawer_about">შესახებ / ლიცენზია</string>
|
||||
<string name="navigation_drawer_beta_feedback">ბეტას უკუკავშირი</string>
|
||||
<string name="install_browser">გთხოვთ, დააყენოთ ვებ ბრაუზერი</string>
|
||||
<string name="navigation_drawer_settings">პარამეტრები</string>
|
||||
<string name="navigation_drawer_news_updates">ახალი ამბები & განახლებები</string>
|
||||
<string name="navigation_drawer_tools">ხელსაწყოები</string>
|
||||
<string name="navigation_drawer_external_links">გარე ბმულები</string>
|
||||
<string name="navigation_drawer_website">ვებ საიტი</string>
|
||||
<string name="navigation_drawer_manual">ინსტრუქცია</string>
|
||||
<string name="navigation_drawer_faq">ხდკ</string>
|
||||
<string name="navigation_drawer_community">საზოგადოება</string>
|
||||
<string name="navigation_drawer_support_project">პროექტის მხარდაჭერა</string>
|
||||
<string name="navigation_drawer_contribute">როგორ შევიტანო ღვაწლი</string>
|
||||
<string name="navigation_drawer_privacy_policy">პირადულობის პოლიტიკა</string>
|
||||
<string name="account_list_no_notification_permission">შეტყობინებები გათიშული. თქვენ არ მიიღებთ შეტყობინებებს სიქნრონიზაციის შეცდომების შესახებ.</string>
|
||||
<string name="account_list_manage_connections">კავშირების მართვა</string>
|
||||
<string name="account_list_datasaver_enabled">გააქტიურებულია მონაცემთა შემნახველი. ფონური სინქრონიზაცია შეზღუდულია.</string>
|
||||
<string name="account_list_manage_datasaver">მონაცემთა შემნახველის მართვა</string>
|
||||
<string name="account_list_battery_saver_enabled">გფააქტიურებულია კვების ელემენტის შემნახველი. სინქრონიზაცია შეიძლება შეზღუდულ იქნას.</string>
|
||||
<string name="account_list_manage_battery_saver">კვების ელემენტის შემნახველის მართვა</string>
|
||||
<string name="account_list_low_storage">მეხსიერება ცოტა დარჩა. Android არ დაასინქრონიზირებს ადგილობრივ ცვლილებებს დაუყონებლივ, ხოლო დაასინქრონიზირებს შემდეგი რეგულარული სინქრონიზაციის დროს.</string>
|
||||
<string name="account_list_manage_storage">მეხსიერების მართვა</string>
|
||||
<string name="accounts_sync_all">ყველა ანგარიშის სინქრონიზაცია</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">სერვისის აღმოჩენა ჩაიშალა</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">კოლექციათა სიის განახლება ვერ მოხერხდა</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<string name="foreground_service_notify_title">მუშაობს ფონში</string>
|
||||
<string name="foreground_service_notify_text">ზოგ მოწყობილობაზე, ეს საჭიროა ავტომატური სინქრონიზაციისთვის.</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">პარამეტრები</string>
|
||||
<string name="app_settings_debug">დებაგი</string>
|
||||
<string name="app_settings_show_debug_info">დებაგის ინფორმაციის ჩვენება</string>
|
||||
<string name="app_settings_logging">დეტალური ჟურნალში ჩაწერა</string>
|
||||
<string name="app_settings_logging_off">ჟურნალში ჩაწერა გათიშულია</string>
|
||||
<string name="app_settings_battery_optimization">კვების ელემენტის ოპტიმიზაცია</string>
|
||||
<string name="app_settings_battery_optimization_exempted">აპი გამორიცხულია (რეკომენდებულია)</string>
|
||||
<string name="app_settings_battery_optimization_optimized">გამოიყენება კვების ელემენტის შეზღუდვები (არა რეკომენდებულია)</string>
|
||||
<string name="app_settings_connection">კავშირი</string>
|
||||
<string name="app_settings_proxy">პროქსის ტიპი</string>
|
||||
<string-array name="app_settings_proxy_types">
|
||||
<item>ნაგულისხმევი სისტემის მიერ</item>
|
||||
<item>პროქსის გარეშე</item>
|
||||
<item>HTTP3</item>
|
||||
<item>SOCKS (Orbot-სთვის)</item>
|
||||
</string-array>
|
||||
<string name="app_settings_proxy_host">პროქსის ჰოსტის სახელი</string>
|
||||
<string name="app_settings_proxy_port">პროქსის პორტი</string>
|
||||
<string name="app_settings_security">უსაფრთხოება</string>
|
||||
<string name="app_settings_security_app_permissions">აპის ეფლებები</string>
|
||||
<string name="app_settings_security_app_permissions_summary">გადახედეთ სინქრონიზაციისთვის საჭირო ეფლებებს</string>
|
||||
<string name="app_settings_distrust_system_certs">სისტემური სერთიფიკატების ნდობის გაუქმება</string>
|
||||
<string name="app_settings_distrust_system_certs_on">სისტემური და მომხმარებლის მიერ დამატებული სერთიფიცირების ავტორიტეტების ნდობა არ იქნება</string>
|
||||
<string name="app_settings_distrust_system_certs_off">სისტემური და მომხმარებლის მიერ დამატებული სერთიფიცირების ავტორიტეტების ნდობა იქნება (რეკომენდებული)</string>
|
||||
<string name="app_settings_reset_certificates">(არა) ნდობითი სერთიფიკატების ჩამოყრა</string>
|
||||
<string name="app_settings_reset_certificates_summary">ნდობის ჩამოყრა ყველა კერძო სერთიფიკატზე</string>
|
||||
<string name="app_settings_reset_certificates_success">ყველა კერძო სერთიფიკატი გასუფთავდა</string>
|
||||
<string name="app_settings_user_interface">მომხმარებლის ინტერფეისი</string>
|
||||
<string name="app_settings_notification_settings">შეტყობინებების პარამეტრები</string>
|
||||
<string name="app_settings_notification_settings_summary">შეტყობინებების არხების და პარამეტრების მართვა</string>
|
||||
<string name="app_settings_theme_title">აირჩიეთ თემა</string>
|
||||
<string-array name="app_settings_theme_names">
|
||||
<item>სისტემის მიერ ნაგულისხმევი</item>
|
||||
<item>ღია</item>
|
||||
<item>მუქი</item>
|
||||
</string-array>
|
||||
<string name="app_settings_reset_hints">მითითებების ჩამოყრა</string>
|
||||
<string name="app_settings_reset_hints_summary">თავიდან ააქტიურებს მითითებებს, რომლებიც დამალულ იქნა წარსულში</string>
|
||||
<string name="app_settings_reset_hints_success">ყველა მითითება თავიდან იქნება ნაჩვენები</string>
|
||||
<string name="app_settings_integration">ინტეგრაცია</string>
|
||||
<string name="app_settings_tasks_provider">დავალებათა აპი</string>
|
||||
<string name="app_settings_tasks_provider_none">თავსებადი დავალებათა აპი ვერ მოიძებნა</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_missing_permissions">საჭიროა დამატებითი უფლებები ამ კოლექციების სინქრონიზაციისთვის.</string>
|
||||
<string name="account_manage_permissions">უფლებების მართვა</string>
|
||||
<string name="account_synchronize_now">ახლავე სინქრონიზირება</string>
|
||||
<string name="account_settings">ანგარიშის პარამეტრები</string>
|
||||
<string name="account_rename">ანგარიშის სახელის შეცვლა</string>
|
||||
<string name="account_rename_new_name_description">შეუნახავი ადგილობრივი მონაცემები შეიძლება გაუქმებულ იქნას. საჭიროა თავიდან სინქრონიზირება სახელის შეცვლის შემდეგ.</string>
|
||||
<string name="account_rename_new_name">ახალი ანგარიშის სახელი</string>
|
||||
<string name="account_rename_rename">სახელის შეცვლა</string>
|
||||
<string name="account_rename_exists_already">ანგარიშის სახელი უკვე დაკავებულია</string>
|
||||
<string name="account_rename_couldnt_rename">ანგარიშის სახელის შეცვლა ვერ მოხერხდა</string>
|
||||
<string name="account_delete">ანგარიშის წაშლა</string>
|
||||
<string name="account_delete_confirmation_title">მართლა წაიშალოს ანგარიში?</string>
|
||||
<string name="account_delete_confirmation_text">წაიშლება მისამართთა წიგნაკების, კალენდრების და დავალებათა სიების ყველა ადგილობრივი ასლი.</string>
|
||||
<string name="account_synchronize_this_collection">ამ კოლექციის სინქრონიზირება</string>
|
||||
<string name="account_read_only">მხოლოდ წაკითხვადი</string>
|
||||
<string name="account_calendar">კალენდარი</string>
|
||||
<string name="account_contacts">კონტაქტები</string>
|
||||
<string name="account_journal">ჟურნალი</string>
|
||||
<string name="account_task_list">დავალებები</string>
|
||||
<string name="account_only_personal">მხოლოდ პირადის ჩვენება</string>
|
||||
<string name="account_refresh_collections">სიის განახლება</string>
|
||||
<string name="account_webcal_external_app">Webcal გამოწერები შეიძ₾ება სინქრონიზირებულ იქნას გარე აპებთან.</string>
|
||||
<string name="account_no_webcal_handler_found">Webcal-თან თავსებადი აპი ვერ მოიძებნა</string>
|
||||
<string name="account_install_icsx5">ICSx⁵-ს დაყენება</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">ანგარიშის დამატება</string>
|
||||
<string name="login_generic_login">ზოგადი შესვლა</string>
|
||||
<string name="login_provider_login">პროვაიდერის შესვლა</string>
|
||||
<string name="login_continue">გაგრძელება</string>
|
||||
<string name="login_login">შესვლა</string>
|
||||
<string name="login_type_email">ელ. ფოსტის მისამართით შესვლა</string>
|
||||
<string name="login_email_address">ელ. ფოსტის მისამართი</string>
|
||||
<string name="login_email_address_error">საჭიროა სწორი ელ. ფოსტის მისამართი</string>
|
||||
<string name="login_email_address_info"><![CDATA[ელ. ფოსტის დომენი გამოიყენება საბაზო URL-ად. <a href="%s">აღმოჩენილია სერვისები</a> DNS ჩანაწერების და კარგად ცნობილი URL-ების მეშვეობით.]]></string>
|
||||
<string name="login_password">პაროლი</string>
|
||||
<string name="login_password_hide">პაროლის დამალვა</string>
|
||||
<string name="login_password_show">პაროლის ჩვენება</string>
|
||||
<string name="login_password_optional">პაროლი*</string>
|
||||
<string name="login_type_url">URL-ით და მომხმარებლის სახელით შესვლა</string>
|
||||
<string name="login_user_name">მომხმარებლის სახელი</string>
|
||||
<string name="login_user_name_optional">მოხმარებლის სახელი*</string>
|
||||
<string name="login_base_url">საბაზო URL</string>
|
||||
<string name="login_base_url_info"><![CDATA[საბაზო URL-ი პირადპირ იქნება შემოწმებული, მაგრამ <a href="%s">ასევე აღმოჩენილია სერვისები</a> DNS ჩანაწერების და კარგად ცნობილი URL-ების მეშვეობით.]]></string>
|
||||
<string name="login_select_certificate">სერტიფიკატის არჩევა</string>
|
||||
<string name="login_add_account">ანგარიშის დამატება</string>
|
||||
<string name="login_account_name">ანგარიშის სახელი</string>
|
||||
<string name="login_account_avoid_apostrophe">აპოსტროფების (\') გამოყენება იწვევს პრობლემებს ზოგ მოწყობილობაზე.</string>
|
||||
<string name="login_account_name_info">გამოიყენეთ თქვენი ელ. ფოსტის მსიამართი ანგარიშის სახელად, რადგან Android გამოიყენებს ანგარიშის სახელს ორგანიზატორის ველში თქვენს მიერ შექმნილ ღონისძიებებისთვის. თქვენ არ შეიძლება გქონდეთ ორი ანგარიში იგივე სახელით.</string>
|
||||
<string name="login_account_contact_group_method">კონტაქტების დაჯგუფების მეთოდი:</string>
|
||||
<string name="login_account_name_required">საჭიროა ანგარიშის სახელი</string>
|
||||
<string name="login_account_name_already_taken">ანგარიშის სახელი უკვე დაკავებულია</string>
|
||||
<string name="login_type_advanced">გაფართოებული შესვლა</string>
|
||||
<string name="login_no_client_certificate_optional">კლიენტის სერტიფიკატი არ არის*</string>
|
||||
<string name="login_client_certificate_selected">კლიენტის სერტიფიკატი: %s</string>
|
||||
<string name="login_no_certificate_found">სერტიფიკატი ვერ მოიძებნა</string>
|
||||
<string name="login_install_certificate">სერტიფიკატის დაყენება</string>
|
||||
<string name="login_type_google">Google კონტაქტები / კალენდარი</string>
|
||||
<string name="login_google_see_tested_with">გთხოვთ, იხილოთ ჩვენი \"ტესტირებულია Google-თან\" გვერდი ბოლო ინფორმაციისთვის.</string>
|
||||
<string name="login_google_unexpected_warnings">შეიძლება გქონდეთ მოულოდნელი გაფრთხილებები ან/და მოგიწიოთ შექმნათ თქვენი პირადი კლიენტის ID.</string>
|
||||
<string name="login_google_account">Google ანგარიში</string>
|
||||
<string name="login_google">Google-ით შესვლა</string>
|
||||
<string name="login_google_client_id">კლიენტის ID (aრასავალდებულო)</string>
|
||||
<string name="login_google_client_privacy_policy"><![CDATA[%1$sგადასცემს თქვენს Google კონტაქტებისა და კალენდარის მონაცემებს მხოლოდ სინქრონიზაციისთვის ამ მოწყობილობასთან. იხილეთ ჩვენი <a href="%2$s">პირადულობის პოლიტიკა</a> დეტალებისთვის.]]></string>
|
||||
<string name="login_google_client_limited_use"><![CDATA[%1$s ექვემდებარება <a href="%2$s">Google API სერვისების მომხმარებელთა მონაცემების პოლიტიკას</a>, მათ შორის, შეზღუდული გამოყენების მოთხოვნებს.]]></string>
|
||||
<string name="login_oauth_couldnt_obtain_auth_code">ავტორიზაციის კოდის მიღება ვერ მოხერხდა</string>
|
||||
<string name="login_type_nextcloud">Nextcloud</string>
|
||||
<string name="login_nextcloud_login_with_nextcloud">შესვლა Nextcloud-ისთ</string>
|
||||
<string name="login_nextcloud_login_flow_text">ეს დაიწყებს Nextcloud-ის შესვლის პროცესს ვებ ბრაუზერში.</string>
|
||||
<string name="login_nextcloud_login_flow_server_address">Nextcloud-ის სერვერის მისამართი</string>
|
||||
<string name="login_nextcloud_login_flow_sign_in">შესვლა</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_url">შესვლის URL-ის მიღება ვერ მოხერხდა</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_data">შესვლის მონაცემების მიღება ვერ მოხერხდა</string>
|
||||
<string name="login_configuration_detection">კონფიგურაციის აღმოჩენა</string>
|
||||
<string name="login_querying_server">გთხოვთ, დაელოდოთ, მიმდინარეობს სერვერის გამოკითხვა...</string>
|
||||
<string name="login_no_service">CalDAV-ის ან CardDAV-ის სერვისის მოძებნა ვერ მოხერხდა.</string>
|
||||
<string name="login_no_service_info">საბაზო URL არ არის წვდომადი CalDAV/CardDAV URL და სერვერისის აღმოჩენა არ იყო წარმატებული.</string>
|
||||
<string name="login_see_tested_services"><![CDATA[გთხოვთ, იხილოთ თქვენი მომსახურების მომწოდებლის ინსტრუქცია და <a href="%s">ჩვენს მიერ ტესტირებული სერვისების სია</a> და მათი საბაზო URL.]]></string>
|
||||
<string name="login_check_credentials">გთხოვთ, ასევე გადაამოწმოთ აუთენტიფიკაცია (ზოგადად, მომხმარებლის სახელი დაპაროლი).</string>
|
||||
<string name="login_logs_available">დამატებითი ტექნიკური ინფორმაცია ხელმისაწვდომია ჟურნალებში.</string>
|
||||
<string name="login_view_logs">ჟურნალების ნახვა</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">სინქრონიზაცია</string>
|
||||
<string name="settings_sync_interval_contacts">კონტაქტების სინქრონიზაციის ინტერვალი</string>
|
||||
<string name="settings_sync_summary_manually">მხოლოდ ხელით</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">ყოველ %d წუთში + დაუყონებლივ ადგილობრივი ცვლილებებისას</string>
|
||||
<string name="settings_sync_interval_calendars">კალენდრების სინქრონიზაციის ინტერვალი</string>
|
||||
<string name="settings_sync_interval_tasks">დავალებვათა სინქრონიზაციის ინტერვალი</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>მხოლოდ ხელით</item>
|
||||
<item>ყოველ 15 წუთში</item>
|
||||
<item>ყოველ 30 წუთში</item>
|
||||
<item>ყოველ 1 საათში</item>
|
||||
<item>ყოველ 2 საათში</item>
|
||||
<item>ყოველ 4 საათში</item>
|
||||
<item>ყოველდღე</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">მხოლოდ WiFi-ით სინქრონიზაცია</string>
|
||||
<string name="settings_sync_wifi_only_on">სინქრონიზაცია შეზღუდულია WiFi კავშირზე</string>
|
||||
<string name="settings_sync_wifi_only_off">კავშირის ტიპი არ გაითვალისწინება</string>
|
||||
<string name="settings_sync_wifi_only_ssids">WiFi SSID-ს შეზღუდვა</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">დასინქრონიზირდება მხოლო %s-ით</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">გამოიყენება ყველა WiFi კავშირი</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">დაშვებული WiFi ქსელების მძიმეთი დაყოფილი სახელები (SSID) (დატოვეთ ცარიელად ყველასთვის)</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_required">WiFi SSID-ს შეზღუდვას სჭირდება დამატებითი პარამეტრები</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">მართვა</string>
|
||||
<string name="settings_ignore_vpns">VPN-ს სჭირდება არსებული ინტერნეტ-კავშირი</string>
|
||||
<string name="settings_ignore_vpns_on">VPN არსებული დადასტურებული ინტერნეტ-კავშირის გარეშე არ არის საკმარისი სინქრონიზაციის გასაშვებად (რეკომენდებული)</string>
|
||||
<string name="settings_ignore_vpns_off">VPN არსებული დადასტურებული ინტერნეტ-კავშირის გარეშე არ არის საკმარისი სინქრონიზაციის გასაშვებად</string>
|
||||
<string name="settings_authentication">აუთენტიფიკაცია</string>
|
||||
<string name="settings_username">მომხმარებლის სახელი</string>
|
||||
<string name="settings_password">პაროლი</string>
|
||||
<string name="settings_new_password">ახალი პაროლი</string>
|
||||
<string name="settings_password_summary">პაროლის განახლება თქვენი სერვერის მიხედვით</string>
|
||||
<string name="settings_certificate_alias">კლიენტის სერთიფიკატი</string>
|
||||
<string name="settings_certificate_alias_empty">სერთიფიკატი ხელმიუწვდომია ან არ არის არჩეული</string>
|
||||
<string name="settings_certificate_install">სერტიფიკატის დაყენება</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">გასული ღონისძიების დროის შეზღუდვა</string>
|
||||
<string name="settings_sync_time_range_past_none">დასინქრონიზირდება ყველა ღონისძიება</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">ერთ დღეზე უფრო ძველი ღონისძიებები იქნება იგნორირებული</item>
|
||||
<item quantity="other">%d დღეზე უფრო ძველი ღონისძიებები იქნება იგნორირებული</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">ღონისძიებები, რომლებიც უფრო ძველია, ვიდრე დღეთა მითითებული რაოდენობა, იქნება იგნორირებული (შეიძლება იყოს 0). დატოვეთ ცარიელად ყველას სინქრონიზებისთვის.</string>
|
||||
<string name="settings_default_alarm">ნაგულისხმევა შეხსენება</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="one">ნაგულისხმევი შეხსენება ღონისძიებამდე ერთი წუთით ადრე</item>
|
||||
<item quantity="other">ნაგულისხმევი შეხსენება ღონისძიებამდე %d წუთით ადრე</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">ნაგულისხმევი შეხსენება არ არის შექმნილი</string>
|
||||
<string name="settings_default_alarm_message">თუ ნაგულისხმევი შეხსენება უნდა შეიქმნას შეხსენების გარეშე ღონისძიებებისთვის: ღონისძიებამდე წუთების სასურველი რიცხვი. დატოვეთ ცარიელად ნაგულისხმევი შეხსენებების გასათიშად.</string>
|
||||
<string name="settings_manage_calendar_colors">კალენდარის ფერების მართვა</string>
|
||||
<string name="settings_manage_calendar_colors_on">კალენდარის ფერები ჩამოიყრება ყოველ სინქრონიზაციაზე</string>
|
||||
<string name="settings_manage_calendar_colors_off">კალენდარის ფერები შეიძლება დაყენებულ იქნას სხვა აპების მიერ</string>
|
||||
<string name="settings_event_colors">ღონისძიების ფერის მხარდაჭერა</string>
|
||||
<string name="settings_event_colors_on">ღონისძიების ფერები არის სინქრონიზირებული</string>
|
||||
<string name="settings_event_colors_off">ღონისძიების ფერები არ არის სინქრონიზირებული</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">კონტაქტების დაჯგუფების მეთოდი</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>ჯგუფები ცალკე vCard-ებია</item>
|
||||
<item>ჯგუფები არის კონტაქტთა კატეგორია</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">მისამართთა წიგნაკის შექმნა</string>
|
||||
<string name="create_addressbook_maybe_not_supported">მისამართთა წიგნაკის შექმნა CardDAV-ით შეიძლება არ იყოს მხარდაჭერილი სერვერის მიერ.</string>
|
||||
<string name="create_calendar">კალენდარის შექმნა</string>
|
||||
<string name="create_calendar_time_zone_optional">ნაგულისხმევი საათის სარტყელი*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">დაშვებული კალენდარის ჩანაწერები</string>
|
||||
<string name="create_calendar_type_vevent">ღონისძიებები</string>
|
||||
<string name="create_calendar_type_vtodo">დავალებები</string>
|
||||
<string name="create_calendar_type_vjournal">შენიშვნები / ჟურნალი</string>
|
||||
<string name="create_calendar_maybe_not_supported">კალენდრის შექმნა CalDAV-ით შეიძლება არ იყოს მხარდაჭერილი სერვერის მიერ.</string>
|
||||
<string name="create_collection_color">ფერი</string>
|
||||
<string name="create_collection_display_name">სათაური</string>
|
||||
<string name="create_collection_home_set">მეხსიერების ადგილმდებარეობა</string>
|
||||
<string name="create_collection_description_optional">აღწერა*</string>
|
||||
<string name="create_collection_create">შექმნა</string>
|
||||
<string name="create_collection_optional">* არასავალდებულო</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">კოლექციის წაშლა</string>
|
||||
<string name="collection_delete_warning">ეს კოლექცია (%s) და მისი ყველა მონაცემი სამუდამოდ წაიშლება, როგორც ადგილობრივად, ისე სერვერზეც.</string>
|
||||
<string name="collection_synchronization">სინქრონიზაცია</string>
|
||||
<string name="collection_synchronization_on">სინქრონიზაცია ჩართულია</string>
|
||||
<string name="collection_synchronization_off">სინქრონიზაცია გამორთულია</string>
|
||||
<string name="collection_read_only">მხოლოდ წაკითხვადი</string>
|
||||
<string name="collection_read_only_by_server">მხოლოდ წაკითხვადი (სერვერის მიერ)</string>
|
||||
<string name="collection_read_only_forced">მხოლოდ წაკითხვადი (მხოლოდ ადგილობრივად)</string>
|
||||
<string name="collection_read_write">წაკითხვა/ჩაწერა</string>
|
||||
<string name="collection_title">სათაური</string>
|
||||
<string name="collection_description">აღწერა</string>
|
||||
<string name="collection_owner">მფლობელი</string>
|
||||
<string name="collection_push_support">Push-ის მხარდაჭერა</string>
|
||||
<string name="collection_push_web_push">სერვერი გადმოსცემს Push-ის მხარდაჭერას</string>
|
||||
<string name="collection_last_sync">ბოლო სინქრონიზაცია (%s)</string>
|
||||
<string name="collection_url">მისამართი (URL)</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">დებაგის ინფო</string>
|
||||
<string name="debug_info_archive_caption">ZIP არქივი</string>
|
||||
<string name="debug_info_archive_subtitle">შეიცავს დებაგის ინფოს და ჟურნალებს</string>
|
||||
<string name="debug_info_archive_text">გააზიარეთ არქივი მისი კომპიუტერზე გადასაგზავნად, ელ. ფოსტით გასაგზავნად ან მისი მხარდაჭერის ბილეთზე მისაბმელად.</string>
|
||||
<string name="debug_info_archive_share">არქივის გაზიარება</string>
|
||||
<string name="debug_info_attached">დებაგის ინფო მიბმულია ამ შეტყობინებაზე (სჭირდება მიბმის მხარდაჭერა მიმღებ აპში).</string>
|
||||
<string name="debug_info_http_error">HTTP შეცდომა</string>
|
||||
<string name="debug_info_server_error">სერვერის შეცდომა</string>
|
||||
<string name="debug_info_webdav_error">WebDAV შეცდომა</string>
|
||||
<string name="debug_info_io_error">წაკითხვა/ჩაწერის შეცდომა</string>
|
||||
<string name="debug_info_http_403_description">ეს მოთხოვნა იქნა უარყოფილი. შეამოწმეთ შესაბამისი რესურსები და დებაგის ინფო დეტალებისთვის.</string>
|
||||
<string name="debug_info_http_404_description">მოთხოვნილი რესურსი (აღარ) არსებობს. შეამოწმეთ შესაბამისი რესურსები და დებაგის ინფო დეტალებისთვის.</string>
|
||||
<string name="debug_info_http_5xx_description">მოხდა პრობლემა სერვერის მხარეს. გთხოვთ, დაუკავშირდეთ თქვენს სერვერის მხარდაჭერას.</string>
|
||||
<string name="debug_info_unexpected_error">მოხდა მოულოდნელი შეცდომა. იხილეთ დებაგის ინფო დეტალებისთვის.</string>
|
||||
<string name="debug_info_view_details">დეტალების ნახვა</string>
|
||||
<string name="debug_info_subtitle">დებაგის ინფო შეგროვდა</string>
|
||||
<string name="debug_info_involved_caption">შესაბამისი რესურსები</string>
|
||||
<string name="debug_info_involved_subtitle">დაკავშირებული პრობლემასთან</string>
|
||||
<string name="debug_info_involved_remote">დაშორებული რესურსი:</string>
|
||||
<string name="debug_info_involved_local">ადგილობრივი რესურსი:</string>
|
||||
<string name="debug_info_logs_caption">ჟურნალები</string>
|
||||
<string name="debug_info_logs_subtitle">ხელმისაწვდომია დეტალური ჟურნალები</string>
|
||||
<string name="debug_info_logs_view">ჟურნალების ნახვა</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">მოხდა შეცდომა.</string>
|
||||
<string name="exception_httpexception">მოხდა HTTP შეცდომა.</string>
|
||||
<string name="exception_ioexception">მოხდა წაკითხვა/ჩაწერის შეცდომა.</string>
|
||||
<string name="exception_show_details">დეტალების ჩვენება.</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_mounts_title">WebDAV-ის მიბმები</string>
|
||||
<string name="webdav_mounts_quota_used_available">გამოყენებული კვოტა: %1$s / ხელმისაწვდომი: %2$s</string>
|
||||
<string name="webdav_mounts_share_content">შიგთავსის გაზიარება</string>
|
||||
<string name="webdav_mounts_unmount">მიბმის გათიშვა</string>
|
||||
<string name="webdav_add_mount_title">WebDAV-ის მიბმის დამატება</string>
|
||||
<string name="webdav_mounts_empty">პირდაპირ იქონიეთ წვდომა თქვენი ღრუბლის ფაილებზე WebDAV-ის მიბმის დამატებით!</string>
|
||||
<string name="webdav_add_mount_empty_more_info"><![CDATA[იხილეთ ინსტრუქცია, თუ <a href="%1$s">როგორ მოქმედებს WebDAV-ის მიბმები</a>.</string>]]></string>
|
||||
<string name="webdav_add_mount_display_name">ნაჩვენები სახელი</string>
|
||||
<string name="webdav_add_mount_url">WebDAV URL</string>
|
||||
<string name="webdav_add_mount_url_invalid">არასწორი URL</string>
|
||||
<string name="webdav_add_mount_authentication">აუთენტიფიკაცია (არასავალდებულო)</string>
|
||||
<string name="webdav_add_mount_username">მომხმარებლის სახელი</string>
|
||||
<string name="webdav_add_mount_password">პაროლი</string>
|
||||
<string name="webdav_add_mount_add">მიბმის დამატება</string>
|
||||
<string name="webdav_add_mount_no_support">WebDAV სერვისი ამ URL-ზე არ არის</string>
|
||||
<string name="webdav_remove_mount_title">მიბმის წერტილის ამოშლა</string>
|
||||
<string name="webdav_remove_mount_text">კავშირის დეტალები დაიკარგება, მაგრამ ფაილები არ წაიშლება.</string>
|
||||
<string name="webdav_notification_access">მიმდინარეობს WebDAV ფაილზე წვდომა</string>
|
||||
<string name="webdav_notification_download">მიმდინარეობს WebDAV ფაილის გადმოტვირთვა</string>
|
||||
<string name="webdav_notification_upload">მიმდინარეობს WebDAV ფაილის ატვირთვა</string>
|
||||
<string name="webdav_provider_root_title">WebDAV-iს მიბმა</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">DAVx⁵-ის უფლებები</string>
|
||||
<string name="sync_error_permissions_text">საჭიროა დამატებითი უფლებები</string>
|
||||
<string name="sync_error_tasks_too_old">%s ნამეტანი ძველია</string>
|
||||
<string name="sync_error_tasks_required_version">მინიმალური საჭირო ვერსია: %1$s</string>
|
||||
<string name="sync_error_authentication_failed">აუთენტიფიკაცია ჩაიშალა (შეამოწმეთ შევლის იდენტიფიკატორები)</string>
|
||||
<string name="sync_error_io">ქსელური ან ჩაწერა/წაკითხვის შეცდომა - %s</string>
|
||||
<string name="sync_error_http_dav">HTTP სერვერის შეცდომა - %s</string>
|
||||
<string name="sync_error_local_storage">ადგილობრივი მეხსიერების შეცდომა - %s</string>
|
||||
<string name="sync_error_retry_limit_reached">რბილის შეცდომა (მიღწეულია თავიდან ცდის მაწსიმუმი)</string>
|
||||
<string name="sync_error_view_item">ჩანაწერის ნახვა</string>
|
||||
<string name="sync_invalid_contact">მიღებულია არასწორი კონტაქტი სერვერიდან</string>
|
||||
<string name="sync_invalid_event">მიღებულია არასწორი ღონისძიება სერვერიდან</string>
|
||||
<string name="sync_invalid_task">მიღებული არასწორი დავალება სერვერიდან</string>
|
||||
<string name="sync_invalid_resources_ignoring">ერთი ან მეტი არასწორი რესურსის იგნორირება</string>
|
||||
<!--widgets-->
|
||||
<string name="widget_sync_all">ყველაფრის სინქრონიზირება</string>
|
||||
<string name="widget_sync_all_accounts">ყველა ანგარიშის სინქრონიზაცია</string>
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,449 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">Account bestaat niet (of niet meer)</string>
|
||||
<string name="account_title_address_book">DAVx⁵ Adresboek</string>
|
||||
<string name="account_prefs_use_app">Verander hier niet van account! Gebruik in plaats daarvan direct de app om accounts te beheren.</string>
|
||||
<string name="dialog_delete">Verwijderen</string>
|
||||
<string name="dialog_remove">Verwijder</string>
|
||||
<string name="dialog_deny">Annuleer</string>
|
||||
<string name="field_required">Dit veld is verplicht</string>
|
||||
<string name="help">Hulp</string>
|
||||
<string name="navigate_up">Navigeer omhoog</string>
|
||||
<string name="optional_label">*optioneel</string>
|
||||
<string name="options_menu">Opties menu</string>
|
||||
<string name="share">Delen</string>
|
||||
<string name="sync_started">Synchronisatie begonnen/in wachtrij geplaatst</string>
|
||||
<string name="database_destructive_migration_title">Database beschadigd</string>
|
||||
<string name="database_destructive_migration_text">Alle accounts zijn lokaal verwijderd.</string>
|
||||
<string name="notification_channel_debugging">Debuggen</string>
|
||||
<string name="notification_channel_general">Andere belangrijke berichten</string>
|
||||
<string name="notification_channel_status">Statusberichten met lage prioriteit</string>
|
||||
<string name="notification_channel_sync">Synchroniseren</string>
|
||||
<string name="notification_channel_sync_errors">Synchronisatiefouten</string>
|
||||
<string name="notification_channel_sync_errors_desc">Belangrijke fouten die het synchroniseren stoppen, zoals onverwachte server antwoorden</string>
|
||||
<string name="notification_channel_sync_warnings">Synchronisatie waarschuwingen</string>
|
||||
<string name="notification_channel_sync_warnings_desc">Niet-fatale problemen bij het synchroniseren zoals bepaalde ongeldige bestanden</string>
|
||||
<string name="notification_channel_sync_io_errors">Netwerk en I/O fouten</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">Timeouts, connectie problemen, etc. (vaak tijdelijk).</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">Jouw gegevens. Jouw keuze.</string>
|
||||
<string name="intro_slogan2">Houd zelf de controle</string>
|
||||
<string name="intro_battery_title">regelmatige sync-intervallen</string>
|
||||
<string name="intro_battery_text">Om op gezette tijden te synchroniseren moet %s zonder beperking op de achtergrond kunnen draaien. Anders kan Android het synchroniseren op elk moment onderbreken.</string>
|
||||
<string name="intro_battery_dont_show">Synchroniseren op gezette tijden is niet nodig.*</string>
|
||||
<string name="intro_autostart_title">%s compatibiliteit</string>
|
||||
<string name="intro_autostart_text">Waarschijnlijk blokkeert dit toestel het synchroniseren. In dat geval is dit alleen handmatig op te lossen.</string>
|
||||
<string name="intro_autostart_dont_show">De vereiste instellingen zijn verricht. Er aan herinneren is niet meer nodig.*</string>
|
||||
<string name="intro_leave_unchecked">* Niet aanvinken om later herinnerd te worden. Kan teruggezet in app instellingen / %s.</string>
|
||||
<string name="intro_more_info">Meer informatie</string>
|
||||
<string name="intro_tasks_jtx">jtx Board</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[Synchroniseert taken, agenda\'s en notities met elke geschikte CalDAV-server.]]></string>
|
||||
<string name="intro_tasks_title">Ondersteunt taken</string>
|
||||
<string name="intro_tasks_text1">Als de server taken ondersteunt, synchroniseert een geschikte taken-app ze:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks</string>
|
||||
<string name="intro_tasks_opentasks_info">Schijnt niet meer ontwikkeld te worden - niet aanbevolen.</string>
|
||||
<string name="intro_tasks_tasks_org">Tasks.org</string>
|
||||
<string name="intro_tasks_tasks_org_info"><![CDATA[Enkele functies <a href="https://www.davx5.com/faq/tasks/advanced-task-features">worden niet ondersteund</a>.]]></string>
|
||||
<string name="intro_tasks_no_app_store">Geen app-store beschikbaar</string>
|
||||
<string name="intro_tasks_dont_show">Ik hoef geen ondersteuning van taken.*</string>
|
||||
<string name="intro_open_source_title">Open-source software</string>
|
||||
<string name="intro_open_source_text">We zijn blij dat de keuze valt op open source software %s. Ontwikkelen, onderhouden en ondersteunen is veel werk. Overweeg daarom bij te dragen (kan op vele manieren) of een donatie. Wij waarderen het zeer!</string>
|
||||
<string name="intro_open_source_details">Hoe bijdragen/doneren</string>
|
||||
<string name="intro_open_source_dont_show">In de nabije toekomst niet weergeven</string>
|
||||
<string name="intro_next">Volgende</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_title">Rechten toestaan</string>
|
||||
<string name="permissions_text">%s heeft rechten nodig om goed te werken.</string>
|
||||
<string name="permissions_all_title">Alle onderstaande</string>
|
||||
<string name="permissions_all_status_off">Gebruik dit om alle functies in te schakelen (aanbevolen)</string>
|
||||
<string name="permissions_all_status_on">Alle rechten toegekend</string>
|
||||
<string name="permissions_contacts_title">Contacten toestaan</string>
|
||||
<string name="permissions_contacts_status_off">Geen contacten synchroniseren (niet aanbevolen)</string>
|
||||
<string name="permissions_contacts_status_on">Contacten synchroniseren mogelijk</string>
|
||||
<string name="permissions_calendar_title">Kalender machtigingen</string>
|
||||
<string name="permissions_calendar_status_off">Geen kalenders synchroniseren (niet aanbevolen)</string>
|
||||
<string name="permissions_calendar_status_on"> Kalenders synchroniseren mogelijk</string>
|
||||
<string name="permissions_notification_title">Toestemming voor meldingen</string>
|
||||
<string name="permissions_notification_status_off">Meldingen uitgeschakeld (niet aanbevolen)</string>
|
||||
<string name="permissions_notification_status_on">Meldingen ingeschakeld</string>
|
||||
<string name="permissions_jtx_title">jtx Board-rechten</string>
|
||||
<string name="permissions_opentasks_title">OpenTasks rechten</string>
|
||||
<string name="permissions_tasksorg_title">Rechten voor taken</string>
|
||||
<string name="permissions_tasks_status_off">Geen taak-sync</string>
|
||||
<string name="permissions_tasks_status_on">Taak-sync mogelijk</string>
|
||||
<string name="permissions_autoreset_title">Rechten behouden</string>
|
||||
<string name="permissions_autoreset_status_off">Rechten kunnen automatisch worden teruggezet (niet aanbevolen)</string>
|
||||
<string name="permissions_autoreset_status_on">Rechten worden niet automatisch teruggezet</string>
|
||||
<string name="permissions_autoreset_instruction">Klik op App Rechten > vinkje uit bij \"Rechten intrekken\"</string>
|
||||
<string name="permissions_app_settings_hint">Als een schakeloptie niet werkt, gebruik dan App-info / Rechten.</string>
|
||||
<string name="permissions_app_settings">App instellingen</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<string name="wifi_permissions_label">WiFi SSID rechten</string>
|
||||
<string name="wifi_permissions_intro">Voor toegang tot de huidige WiFi-naam (SSID), moet aan deze voorwaarden worden voldaan:</string>
|
||||
<string name="wifi_permissions_location_permission">Recht van toegang tot exacte locatie</string>
|
||||
<string name="wifi_permissions_location_permission_on">Toegang tot locatie verleend</string>
|
||||
<string name="wifi_permissions_location_permission_off">Toegang tot locatie geweigerd</string>
|
||||
<string name="wifi_permissions_background_location_permission">Toegang tot locatie op de achtergrond</string>
|
||||
<string name="wifi_permissions_background_location_permission_label">Onbeperkt toestaan</string>
|
||||
<string name="wifi_permissions_background_location_permission_on">Locatietoestemming ingesteld op: %s</string>
|
||||
<string name="wifi_permissions_background_location_permission_off">Locatietoestemming niet ingesteld op: %s</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer">%s gebruikt locatiegegevens (alleen WiFi SSID) uitsluitend om de synchronisatie te beperken tot een specifieke WiFi SSID. Dit gebeurt zelfs als de synchronisatie op de achtergrond wordt uitgevoerd.</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer2">Alle locatiegegevens (alleen WiFi SSID) worden alleen lokaal gebruikt en worden nergens naartoe verzonden.</string>
|
||||
<string name="wifi_permissions_location_enabled">Toegang tot locatie altijd ingeschakeld</string>
|
||||
<string name="wifi_permissions_location_enabled_on">Toegang tot locatie is ingeschakeld</string>
|
||||
<string name="wifi_permissions_location_enabled_off">Toegang tot locatie is uitgeschakeld</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_translations">Vertalingen</string>
|
||||
<string name="about_libraries">Bibliotheken</string>
|
||||
<string name="about_version">Versie%1$s (%2$d)</string>
|
||||
<string name="about_copyright">© Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) en bijdragers</string>
|
||||
<string name="about_license_info_no_warranty">Dit programma wordt geleverd met ABSOLUUT GEEN GARANTIE. Het is gratis software, en mag opnieuw worden verspreid onder bepaalde voorwaarden.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">Kon geen logbestand aanmaken</string>
|
||||
<string name="logging_notification_text">Logt nu alle %s activiteiten</string>
|
||||
<string name="logging_notification_view_share">Bekijken/delen</string>
|
||||
<string name="logging_notification_disable">Uitschakelen</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV Sync adapter</string>
|
||||
<string name="navigation_drawer_about">Over / Licentie</string>
|
||||
<string name="navigation_drawer_beta_feedback">Beta terugkoppeling</string>
|
||||
<string name="install_browser">Webbrowser is vereist</string>
|
||||
<string name="navigation_drawer_settings">Instellingen</string>
|
||||
<string name="navigation_drawer_news_updates">Nieuws & updates</string>
|
||||
<string name="navigation_drawer_tools">Gereedschap</string>
|
||||
<string name="navigation_drawer_external_links">Externe links</string>
|
||||
<string name="navigation_drawer_website">Website</string>
|
||||
<string name="navigation_drawer_manual">Handleiding</string>
|
||||
<string name="navigation_drawer_faq">FAQ</string>
|
||||
<string name="navigation_drawer_community">Community</string>
|
||||
<string name="navigation_drawer_support_project">Ondersteun het project</string>
|
||||
<string name="navigation_drawer_contribute">Hoe bijdragen</string>
|
||||
<string name="navigation_drawer_privacy_policy">Privacybeleid</string>
|
||||
<string name="account_list_no_notification_permission">Meldingen uitgeschakeld. U krijgt geen meldingen over synchronisatiefouten.</string>
|
||||
<string name="account_list_manage_connections">Verbindingen beheren</string>
|
||||
<string name="account_list_datasaver_enabled">Gegevensbesparing ingeschakeld. Synchronisatie op de achtergrond is beperkt.</string>
|
||||
<string name="account_list_manage_datasaver">Beheer van gegevensbesparing</string>
|
||||
<string name="account_list_battery_saver_enabled">Batterijbesparing ingeschakeld. Synchronisatie kan beperkt zijn.</string>
|
||||
<string name="account_list_manage_battery_saver">Batterijbesparing beheren</string>
|
||||
<string name="account_list_low_storage">Weinig opslagruimte. Android zal lokale wijzigingen niet onmiddellijk synchroniseren, maar tijdens de volgende reguliere synchronisatie.</string>
|
||||
<string name="account_list_manage_storage">Opslag beheren</string>
|
||||
<string name="account_list_welcome">Welkom bij DAVx⁵!</string>
|
||||
<string name="account_list_empty">Maak verbinding met je server en houd je agenda\'s en contactpersonen gesynchroniseerd.</string>
|
||||
<string name="accounts_sync_all">Alle accounts synchroniseren</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">Service herkenning is mislukt</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">De collectielijst is niet bijgewerkt</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<string name="foreground_service_notify_title">Draait op de voorgrond</string>
|
||||
<string name="foreground_service_notify_text">Op sommige toestellen is dit nodig voor automatische synchronisatie.</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Instellingen</string>
|
||||
<string name="app_settings_debug">Debuggen</string>
|
||||
<string name="app_settings_show_debug_info">Debug-info</string>
|
||||
<string name="app_settings_show_debug_info_details">Configuratiedetails en logbestanden bekijken/delen</string>
|
||||
<string name="app_settings_logging">Uitgebreid loggen</string>
|
||||
<string name="app_settings_logging_on">Loggen is actief. Je kunt de logs bekijken als onderdeel van de debug-info.</string>
|
||||
<string name="app_settings_logging_off">Loggen is niet actief</string>
|
||||
<string name="app_settings_battery_optimization">Batterijoptimalisatie</string>
|
||||
<string name="app_settings_battery_optimization_exempted">App is vrijgesteld (aanbevolen)</string>
|
||||
<string name="app_settings_battery_optimization_optimized">Batterijbeperkingen van toepassing (niet aanbevolen)</string>
|
||||
<string name="app_settings_connection">Verbinding</string>
|
||||
<string name="app_settings_proxy">Proxy type</string>
|
||||
<string-array name="app_settings_proxy_types">
|
||||
<item>Systeem standaard</item>
|
||||
<item>Geen proxy</item>
|
||||
<item>HTTP</item>
|
||||
<item>SOCKS (voor Orbot)</item>
|
||||
</string-array>
|
||||
<string name="app_settings_proxy_host">Proxy host naam</string>
|
||||
<string name="app_settings_proxy_port">Proxy poort</string>
|
||||
<string name="app_settings_security">Beveiliging</string>
|
||||
<string name="app_settings_security_app_permissions">App rechten</string>
|
||||
<string name="app_settings_security_app_permissions_summary">De vereiste rechten om te synchroniseren controleren</string>
|
||||
<string name="app_settings_distrust_system_certs">Wantrouw systeemcertificaten</string>
|
||||
<string name="app_settings_distrust_system_certs_on">Door systeem en gebruiker toegevoegde CA certificaten niet vertrouwen</string>
|
||||
<string name="app_settings_distrust_system_certs_off">Door systeem en gebruiker toegevoegde CA certificaten vertrouwen (aanbevolen)</string>
|
||||
<string name="app_settings_reset_certificates">(Niet-)vertrouwde certificaten terugzetten</string>
|
||||
<string name="app_settings_reset_certificates_summary">Herstelt het vertrouwen van alle aangepaste certificaten</string>
|
||||
<string name="app_settings_reset_certificates_success">Alle aangepaste certificaten zijn gewist</string>
|
||||
<string name="app_settings_user_interface">Gebruikersinterface</string>
|
||||
<string name="app_settings_notification_settings">App-meldingen</string>
|
||||
<string name="app_settings_notification_settings_summary">Meldingskanalen en hun instellingen beheren</string>
|
||||
<string name="app_settings_theme_title">Thema selecteren</string>
|
||||
<string-array name="app_settings_theme_names">
|
||||
<item>Systeem standaard</item>
|
||||
<item>Licht</item>
|
||||
<item>Donker</item>
|
||||
</string-array>
|
||||
<string name="app_settings_reset_hints">Hints opnieuw instellen</string>
|
||||
<string name="app_settings_reset_hints_summary">Hints die al gezien zijn opnieuw weergeven</string>
|
||||
<string name="app_settings_reset_hints_success">Alle hints opnieuw weergeven</string>
|
||||
<string name="app_settings_integration">Integratie</string>
|
||||
<string name="app_settings_tasks_provider">Taken app</string>
|
||||
<string name="app_settings_tasks_provider_none">Geen compatibele taken app gevonden</string>
|
||||
<string name="app_settings_unifiedpush">UnifiedPush (experimenteel)</string>
|
||||
<string name="app_settings_unifiedpush_disable">Geen (push uitschakelen)</string>
|
||||
<string name="app_settings_unifiedpush_choose_distributor">Kies een distributeur</string>
|
||||
<string name="app_settings_unifiedpush_no_distributor">Geen push distributeur geïnstalleerd</string>
|
||||
<string name="app_settings_unifiedpush_no_endpoint">Geen eindpunt geconfigureerd</string>
|
||||
<string name="app_settings_unifiedpush_ready">Klaar om pushberichten te ontvangen via %s</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_missing_permissions">Er zijn extra rechten nodig om deze collecties te synchroniseren.</string>
|
||||
<string name="account_manage_permissions">Machtigingen beheren</string>
|
||||
<string name="account_synchronize_now">Nu synchroniseren</string>
|
||||
<string name="account_settings">Account instellingen</string>
|
||||
<string name="account_rename">Naam account wijzigen</string>
|
||||
<string name="account_rename_new_name_description">Niet opgeslagen lokale gegevens kunnen worden verwijderd. Na het hernoemen is opnieuw synchroniseren vereist.</string>
|
||||
<string name="account_rename_new_name">Nieuwe accountnaam</string>
|
||||
<string name="account_rename_rename">Naam wijzigen</string>
|
||||
<string name="account_rename_exists_already">Accountnaam is al in gebruik</string>
|
||||
<string name="account_rename_couldnt_rename">Naam account is niet gewijzigd</string>
|
||||
<string name="account_delete">Account verwijderen</string>
|
||||
<string name="account_delete_confirmation_title">Account echt verwijderen?</string>
|
||||
<string name="account_delete_confirmation_text">Alle lokale kopieën van adresboeken, kalenders en takenlijsten worden verwijderd.</string>
|
||||
<string name="account_synchronize_this_collection">deze collectie synchroniseren</string>
|
||||
<string name="account_read_only">alleen-lezen</string>
|
||||
<string name="account_calendar">kalender</string>
|
||||
<string name="account_contacts">contacten</string>
|
||||
<string name="account_journal">logboek</string>
|
||||
<string name="account_task_list">taken</string>
|
||||
<string name="account_only_personal">Alleen persoonlijk tonen</string>
|
||||
<string name="account_refresh_collections">Lijst verversen</string>
|
||||
<string name="account_webcal_external_app">Webcal abonnementen kunnen worden gesynchroniseerd met externe apps.</string>
|
||||
<string name="account_no_webcal_handler_found">Geen Webcal-app gevonden</string>
|
||||
<string name="account_install_icsx5">ICSx⁵ installeren</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Account toevoegen</string>
|
||||
<string name="login_privacy_hint"><![CDATA[Alle gegevens worden alleen overgedragen tussen je server en je apparaat. %1$s zal ze nergens anders naartoe sturen. Zie<a href="%2$s">privacybeleid</a>.]]></string>
|
||||
<string name="login_generic_login">Algemeen inloggen</string>
|
||||
<string name="login_provider_login">Aanbieder-specifieke login</string>
|
||||
<string name="login_continue">Ga verder</string>
|
||||
<string name="login_login">Login</string>
|
||||
<string name="login_type_email">Inloggen met e-mailadres</string>
|
||||
<string name="login_email_address">E-mailadres</string>
|
||||
<string name="login_email_address_error">Geldig e-mailadres vereist</string>
|
||||
<string name="login_email_address_info"><![CDATA[Het e-maildomein wordt gebruikt als basis-URL. <a href="%s">Diensten worden ontdekt</a> met behulp van DNS-records en bekende URL\'s.]]></string>
|
||||
<string name="login_password">Wachtwoord</string>
|
||||
<string name="login_password_hide">Verberg wachtwoord</string>
|
||||
<string name="login_password_show">Wachtwoord tonen</string>
|
||||
<string name="login_password_optional">Wachtwoord*</string>
|
||||
<string name="login_type_url">Inloggen met URL en gebruikersnaam</string>
|
||||
<string name="login_user_name">Gebruikersnaam</string>
|
||||
<string name="login_user_name_optional">Gebruikersnaam*</string>
|
||||
<string name="login_base_url">Basis-URL</string>
|
||||
<string name="login_base_url_info"><![CDATA[De basis URL wordt direct gecontroleerd, maar <a href="%s">services worden ook ontdekt</a> met behulp van DNS records en bekende URL\'s.]]></string>
|
||||
<string name="login_select_certificate">Certificaat selecteren</string>
|
||||
<string name="login_add_account">Account toevoegen</string>
|
||||
<string name="login_account_name">Accountnaam</string>
|
||||
<string name="login_account_avoid_apostrophe">Het gebruik van apostrofs (\') lijkt problemen te veroorzaken op sommige apparaten.</string>
|
||||
<string name="login_account_name_info">Gebruik het eigen e-mailadres als accountnaam, want Android gebruikt het als ORGANIZER veld voor gebeurtenissen. Twee accounts met hetzelfde adres kan niet.</string>
|
||||
<string name="login_account_contact_group_method">Methode voor contact-groepen:</string>
|
||||
<string name="login_account_name_required">Accountnaam verplicht</string>
|
||||
<string name="login_account_name_already_taken">Accountnaam is al in gebruik</string>
|
||||
<string name="login_account_not_added">Account kon niet worden toegevoegd</string>
|
||||
<string name="login_finish">afwerken</string>
|
||||
<string name="login_type_advanced">Geavanceerd inloggen</string>
|
||||
<string name="login_no_client_certificate_optional">Geen cliëntcertificaat*</string>
|
||||
<string name="login_client_certificate_selected">Cliëntcertificaat: %s</string>
|
||||
<string name="login_no_certificate_found">Geen certificaat gevonden</string>
|
||||
<string name="login_install_certificate">Certificaat installeren</string>
|
||||
<string name="login_type_google">Google Contacten / Kalender</string>
|
||||
<string name="login_google_see_tested_with">Raadpleeg onze pagina \"Getest met Google\" voor actuele informatie.</string>
|
||||
<string name="login_google_unexpected_warnings">Het kan zijn dat je onverwachte waarschuwingen krijgt en/of je eigen client-ID moet aanmaken.</string>
|
||||
<string name="login_google_account">Google account</string>
|
||||
<string name="login_google">Inloggen met Google</string>
|
||||
<string name="login_google_client_id">Client ID (optioneel)</string>
|
||||
<string name="login_google_client_privacy_policy"><![CDATA[%1$s draagt uw Google Contacten en Agenda gegevens uitsluitend over voor synchronisatie met dit apparaat. Zie ons Privacybeleid voor meer informatie. Zie ons <a href="%2$s">Privacybeleid</a> voor meer informatie.]]></string>
|
||||
<string name="login_google_client_limited_use"><![CDATA[%1$s voldoet aan het <a href="%2$s">beleid voor gebruikersgegevens van Google API Services</a>, met inbegrip van de vereisten voor beperkt gebruik.]]></string>
|
||||
<string name="login_oauth_couldnt_obtain_auth_code">Kon geen autorisatiecode verkrijgen</string>
|
||||
<string name="login_type_nextcloud">Nextcloud</string>
|
||||
<string name="login_nextcloud_login_with_nextcloud">Inloggen met Nextcloud</string>
|
||||
<string name="login_nextcloud_login_flow_text">Hiermee wordt de Nextcloud Flow-aanmelding in een webbrowser gestart.</string>
|
||||
<string name="login_nextcloud_login_flow_server_address">Nextcloud serveradres</string>
|
||||
<string name="login_nextcloud_login_flow_sign_in">Aanmelden</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_url">Kan inlog-URL niet verkrijgen</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_data">Kan inlog-URL niet verkrijgen</string>
|
||||
<string name="login_configuration_detection">Configuratie detecteren</string>
|
||||
<string name="login_querying_server">Even geduld, verzoek naar server…</string>
|
||||
<string name="login_no_service">Geen CalDAV- of CardDAV-service gevonden.</string>
|
||||
<string name="login_no_service_info">De basis URL lijkt geen toegankelijke CalDAV/CardDAV URL te zijn en de detectie van de service was niet succesvol.</string>
|
||||
<string name="login_see_tested_services"><![CDATA[Raadpleeg de handleiding van uw serviceprovider en <a href="%s">onze lijst met geteste services</a> en hun basis URL\'s.]]></string>
|
||||
<string name="login_check_credentials">Controleer ook de authenticatie (meestal gebruikersnaam en wachtwoord).</string>
|
||||
<string name="login_logs_available">Meer technische informatie is beschikbaar in de logboeken.</string>
|
||||
<string name="login_view_logs">Details bekijken</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">Synchronisatie</string>
|
||||
<string name="settings_sync_interval_contacts">Contacten synchronisatie interval</string>
|
||||
<string name="settings_sync_summary_manually">Alleen handmatig</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Elke %d minuten + direct bij lokale veranderingen</string>
|
||||
<string name="settings_sync_interval_calendars">Kalenders synchronisatie-interval</string>
|
||||
<string name="settings_sync_interval_tasks">Taken synchronisatie-interval</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>Handmatig </item>
|
||||
<item>Elke 15 minuten </item>
|
||||
<item>Elke 30 minuten</item>
|
||||
<item>Elk uur</item>
|
||||
<item>Elke 2 uur</item>
|
||||
<item>Elke 4 uur</item>
|
||||
<item>Eenmaal daags</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">Synchronisatie beperken tot WiFi</string>
|
||||
<string name="settings_sync_wifi_only_on">Alleen verbinden via WiFi</string>
|
||||
<string name="settings_sync_wifi_only_off">Type verbinding is niet relevant</string>
|
||||
<string name="settings_sync_wifi_only_ssids">Tot bepaalde WiFi-SSID beperken</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Synchronisatie alleen via %s</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Elke WiFI-SSID toestaan</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">Door komma\'s gescheiden namen (SSID\'s) van toegestane WiFi-netwerken (laat leeg voor alle)</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_required">Beperking WiFi-SSID vereist verdere instellingen</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">Beheren</string>
|
||||
<string name="settings_ignore_vpns">VPN vereist onderliggend internet</string>
|
||||
<string name="settings_ignore_vpns_on">VPN zonder onderliggende gevalideerde internetverbinding is niet voldoende om synchronisatie uit te voeren (aanbevolen)</string>
|
||||
<string name="settings_ignore_vpns_off">VPN zonder onderliggende gevalideerde internetverbinding is voldoende om synchronisatie uit te voeren</string>
|
||||
<string name="settings_authentication">Authenticatie</string>
|
||||
<string name="settings_username">Gebruikersnaam</string>
|
||||
<string name="settings_password">Wachtwoord</string>
|
||||
<string name="settings_new_password">Nieuw wachtwoord</string>
|
||||
<string name="settings_password_summary">Gebruik het zelfde wachtwoord als op de server.</string>
|
||||
<string name="settings_certificate_alias">Cliëntcertificaat</string>
|
||||
<string name="settings_certificate_alias_empty">Geen certificaat beschikbaar of geselecteerd</string>
|
||||
<string name="settings_certificate_install">Certificaat installeren</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Gebeurtenissen in verleden tijd</string>
|
||||
<string name="settings_sync_time_range_past_none">Worden alle gesynchroniseerd</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Afspraken ouder dan een dag worden genegeerd</item>
|
||||
<item quantity="other">Ouder dan %d dagen worden genegeerd</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Gebeurtenissen ouder dan ingevuld aantal dagen worden genegeerd (mag 0 zijn). Veld leeg laten om alle te synchroniseren.</string>
|
||||
<string name="settings_default_alarm">Standaardherinnering</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="one">Standaardherinnering één minut voor het evenement</item>
|
||||
<item quantity="other"> %d minuten voor aanvang gebeurtenis</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">Wordt niet aangemaakt</string>
|
||||
<string name="settings_default_alarm_message">Vul het gewenste aantal minuten in. Leeg laten om herinneringen uit te schakelen.</string>
|
||||
<string name="settings_manage_calendar_colors">Kalender kleuren beheren</string>
|
||||
<string name="settings_manage_calendar_colors_on">Worden bij elke sync teruggezet</string>
|
||||
<string name="settings_manage_calendar_colors_off">Kunnen door andere apps worden ingesteld</string>
|
||||
<string name="settings_event_colors">Gebeurtenis kleuren ondersteunen</string>
|
||||
<string name="settings_event_colors_on">Worden gesynchroniseerd</string>
|
||||
<string name="settings_event_colors_off">Worden niet gesynchroniseerd</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Methode voor contact-groepen:</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>Groepen zijn afzonderlijke vCards</item>
|
||||
<item>Groepen zijn categorieën per contact</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">Adresboek aanmaken</string>
|
||||
<string name="create_addressbook_maybe_not_supported">Het aanmaken van een adresboek via CardDAV wordt mogelijk niet ondersteund door de server.</string>
|
||||
<string name="create_calendar">Kalender aanmaken</string>
|
||||
<string name="create_calendar_time_zone_optional">Standaard tijdzone*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">Mogelijke kalender-items</string>
|
||||
<string name="create_calendar_type_vevent">Gebeurtenissen</string>
|
||||
<string name="create_calendar_type_vtodo">Taken</string>
|
||||
<string name="create_calendar_type_vjournal">Notities / Dagboek</string>
|
||||
<string name="create_calendar_maybe_not_supported">Het aanmaken van een kalender via CalDAV wordt mogelijk niet ondersteund door de server.</string>
|
||||
<string name="create_collection_color">Kleur</string>
|
||||
<string name="create_collection_display_name">Titel</string>
|
||||
<string name="create_collection_home_set">Opslaglocatie</string>
|
||||
<string name="create_collection_description_optional">Beschrijving*</string>
|
||||
<string name="create_collection_create">Aanmaken</string>
|
||||
<string name="create_collection_optional">*optioneel</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">Collectie verwijderen</string>
|
||||
<string name="collection_delete_warning">Deze collectie (%s) en alle gegevens worden permanent verwijderd, zowel lokaal als op de server.</string>
|
||||
<string name="collection_synchronization">Synchroniseren</string>
|
||||
<string name="collection_synchronization_on">Synchronisatie ingeschakeld</string>
|
||||
<string name="collection_synchronization_off">Synchronisatie uitgeschakeld</string>
|
||||
<string name="collection_read_only">Alleen-lezen</string>
|
||||
<string name="collection_read_only_by_server">Alleen-lezen (door server)</string>
|
||||
<string name="collection_read_only_by_setting">Alleen-lezen (volgens beleid)</string>
|
||||
<string name="collection_read_only_forced">Alleen-lezen (alleen lokaal)</string>
|
||||
<string name="collection_read_write">Lezen/schrijven</string>
|
||||
<string name="collection_title">Titel</string>
|
||||
<string name="collection_description">Beschrijving</string>
|
||||
<string name="collection_owner">Eigenaar</string>
|
||||
<string name="collection_push_support">Push-ondersteuning</string>
|
||||
<string name="collection_push_web_push">Server adverteert Push-ondersteuning</string>
|
||||
<string name="collection_push_subscribed_at">Ingeschreven op %1$s, vervalt op %2$s</string>
|
||||
<string name="collection_last_sync">Laatste gesynchroniseerd (%s)</string>
|
||||
<string name="collection_url">Adres (URL)</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Debug informatie</string>
|
||||
<string name="debug_info_archive_caption">ZIP archief</string>
|
||||
<string name="debug_info_archive_subtitle">Bevat debuginformatie en logbestanden</string>
|
||||
<string name="debug_info_archive_text">Deel het archief om over te zetten naar een computer, per e-mail te verzenden of als bijlage bij een supportticket te voegen..</string>
|
||||
<string name="debug_info_archive_share">Archief delen</string>
|
||||
<string name="debug_info_attached">Debug info als bijlage bij dit bericht (vereist ondersteuning voor bijlagen van de ontvangende app).</string>
|
||||
<string name="debug_info_http_error">HTTP-fout</string>
|
||||
<string name="debug_info_server_error">Serverfout</string>
|
||||
<string name="debug_info_webdav_error">WebDAV fout</string>
|
||||
<string name="debug_info_io_error">I/O-fout</string>
|
||||
<string name="debug_info_http_403_description">Het verzoek is afgewezen. Controleer de betrokken bronnen en debug-info voor details.</string>
|
||||
<string name="debug_info_http_404_description">De gevraagde bron bestaat niet (meer). Controleer de betrokken bronnen en debug-info voor details.</string>
|
||||
<string name="debug_info_http_5xx_description">Er is bij de server een probleem opgetreden. Neem contact op met de server-ondersteuning.</string>
|
||||
<string name="debug_info_unexpected_error">Er is een onverwachte fout opgetreden. Bekijk debug-info voor details.</string>
|
||||
<string name="debug_info_view_details">Details bekijken</string>
|
||||
<string name="debug_info_subtitle">Debug-info is verzameld</string>
|
||||
<string name="debug_info_involved_caption">Betrokken bronnen</string>
|
||||
<string name="debug_info_involved_subtitle">Gerelateerd aan het probleem</string>
|
||||
<string name="debug_info_involved_remote">Externe bron:</string>
|
||||
<string name="debug_info_involved_local">Lokale bron:</string>
|
||||
<string name="debug_info_logs_caption">Logboeken</string>
|
||||
<string name="debug_info_logs_subtitle">Uitgebreide logboeken zijn beschikbaar</string>
|
||||
<string name="debug_info_logs_view">Details bekijken</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Er is een fout opgetreden.</string>
|
||||
<string name="exception_httpexception">Een HTTP-fout is opgetreden.</string>
|
||||
<string name="exception_ioexception">Een I/O fout is opgetreden.</string>
|
||||
<string name="exception_show_details">Details weergeven</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_mounts_title">WebDAV-koppelingen</string>
|
||||
<string name="webdav_mounts_quota_used_available">Quotum gebruikt: %1$s / Beschikbaar: %2$s</string>
|
||||
<string name="webdav_mounts_share_content">Inhoud delen</string>
|
||||
<string name="webdav_mounts_unmount">Ontkoppelen</string>
|
||||
<string name="webdav_add_mount_title">WebDAV-koppeling toevoegen</string>
|
||||
<string name="webdav_mounts_empty">Verkrijg directe toegang tot cloudbestanden met een WebDAV-koppeling!</string>
|
||||
<string name="webdav_add_mount_empty_more_info"><![CDATA[Zie de handleiding over <a href="%1$s"> het koppelen van WebDAV</a>.</string>]]></string>
|
||||
<string name="webdav_add_mount_display_name">Weergavenaam</string>
|
||||
<string name="webdav_add_mount_url">WebDAV-URL</string>
|
||||
<string name="webdav_add_mount_url_invalid">Ongeldige URL</string>
|
||||
<string name="webdav_add_mount_authentication">Authenticatie (optioneel)</string>
|
||||
<string name="webdav_add_mount_username">Gebruikersnaam</string>
|
||||
<string name="webdav_add_mount_password">Wachtwoord</string>
|
||||
<string name="webdav_add_mount_add">Koppeling toevoegen</string>
|
||||
<string name="webdav_add_mount_no_support">Geen WebDAV-service op deze URL</string>
|
||||
<string name="webdav_remove_mount_title">Verwijder het koppelpunt</string>
|
||||
<string name="webdav_remove_mount_text">Verbindingsgegevens gaan verloren, maar er worden geen bestanden gewist.</string>
|
||||
<string name="webdav_notification_access">WebDAV-bestand openen</string>
|
||||
<string name="webdav_notification_download">WebDAV-bestand downloaden</string>
|
||||
<string name="webdav_notification_upload">WebDAV-bestand uploaden</string>
|
||||
<string name="webdav_provider_root_title">WebDAV-koppeling</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">DAVx⁵ rechten</string>
|
||||
<string name="sync_error_permissions_text">Aanvullende rechten vereist</string>
|
||||
<string name="sync_error_tasks_too_old">%ste oud</string>
|
||||
<string name="sync_error_tasks_required_version">Minimaal vereiste versie: %1$s</string>
|
||||
<string name="sync_error_authentication_failed">Verificatie mislukt (controleer aanmeldingsgegevens)</string>
|
||||
<string name="sync_error_io">Netwerk of I/O error - %s</string>
|
||||
<string name="sync_error_http_dav">HTTP-server fout - %s</string>
|
||||
<string name="sync_error_local_storage">Lokale opslag fout - %s</string>
|
||||
<string name="sync_error_retry_limit_reached">Soft error (max. aantal pogingen bereikt)</string>
|
||||
<string name="sync_error_view_item">Item bekijken</string>
|
||||
<string name="sync_invalid_contact">Ongeldig contact ontvangen van server</string>
|
||||
<string name="sync_invalid_event">Ongeldige gebeurtenis ontvangen van server</string>
|
||||
<string name="sync_invalid_task">Ongeldige taak ontvangen van server</string>
|
||||
<string name="sync_invalid_resources_ignoring">Een of meer ongeldige bronnen negeren</string>
|
||||
<string name="sync_notification_pending_push_title">Synchronisatie in afwachting</string>
|
||||
<string name="sync_notification_pending_push_message">De gegevens op afstand zijn veranderd</string>
|
||||
<!--widgets-->
|
||||
<string name="widget_sync_all">Alles synchroniseren</string>
|
||||
<string name="widget_sync_all_accounts">Alle accounts synchroniseren</string>
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,446 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">Contul nu (mai) există</string>
|
||||
<string name="account_title_address_book">Agenda DAVx⁵</string>
|
||||
<string name="dialog_delete">Șterge</string>
|
||||
<string name="dialog_remove">Elimină</string>
|
||||
<string name="dialog_deny">Anulează</string>
|
||||
<string name="field_required">Acest câmp este obligatoriu</string>
|
||||
<string name="help">Ajutor</string>
|
||||
<string name="navigate_up">Navigare în sus</string>
|
||||
<string name="optional_label">* opțional</string>
|
||||
<string name="options_menu">Meniul Opțiuni</string>
|
||||
<string name="share">Distribuie</string>
|
||||
<string name="sync_started">Sincronizare începută/pusă în coadă</string>
|
||||
<string name="database_destructive_migration_title">Bază de date deteriorată</string>
|
||||
<string name="database_destructive_migration_text">Toate conturile au fost eliminate local.</string>
|
||||
<string name="notification_channel_debugging">Depanare</string>
|
||||
<string name="notification_channel_general">Alte mesaje importante</string>
|
||||
<string name="notification_channel_status">Mesaje de stare cu prioritate redusă</string>
|
||||
<string name="notification_channel_sync">Sincronizare</string>
|
||||
<string name="notification_channel_sync_errors">Erori de sincronizare</string>
|
||||
<string name="notification_channel_sync_errors_desc">Erori importante care opresc sincronizarea, cum ar fi răspunsurile neașteptate ale serverului</string>
|
||||
<string name="notification_channel_sync_warnings">Avertismente de sincronizare</string>
|
||||
<string name="notification_channel_sync_warnings_desc">Probleme de sincronizare non-fatale, cum ar fi anumite fișiere nevalide</string>
|
||||
<string name="notification_channel_sync_io_errors">Erori de rețea și I/O</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">Expirare, probleme de conexiune etc. (adesea temporare)</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">Datele tale. Alegerea ta.</string>
|
||||
<string name="intro_slogan2">Preia controlul.</string>
|
||||
<string name="intro_battery_title">Intervale regulate de sincronizare</string>
|
||||
<string name="intro_battery_text">Pentru sincronizare la intervale regulate, %s trebuie să aibă voie să ruleze în fundal. În caz contrar, Android poate întrerupe sincronizarea în orice moment.</string>
|
||||
<string name="intro_battery_dont_show">Nu am nevoie de intervale regulate de sincronizare.*</string>
|
||||
<string name="intro_autostart_title">Compatibilitate %s </string>
|
||||
<string name="intro_autostart_text">Acest dispozitiv probabil blochează sincronizarea. Dacă ești afectat, poți rezolva acest lucru numai manual.</string>
|
||||
<string name="intro_autostart_dont_show">Am făcut setările necesare. Nu-mi mai aminti.*</string>
|
||||
<string name="intro_leave_unchecked">* Lasă nebifat pentru a fi reamintit mai târziu. Poate fi resetat în setările aplicației / %s.</string>
|
||||
<string name="intro_more_info">Mai multe informații</string>
|
||||
<string name="intro_tasks_jtx">Placă de bază jtx</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[Acceptă sincronizarea sarcinilor, jurnalelor și notelor.]]></string>
|
||||
<string name="intro_tasks_title">Suport pentru sarcini</string>
|
||||
<string name="intro_tasks_text1">Dacă sarcinile sunt acceptate de server, acestea pot fi sincronizate cu o aplicație de sarcini acceptată:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks</string>
|
||||
<string name="intro_tasks_opentasks_info">Nu pare a mai fi dezvoltat – nu este recomandat.</string>
|
||||
<string name="intro_tasks_tasks_org">Tasks.org</string>
|
||||
<string name="intro_tasks_tasks_org_info"><![CDATA[Unele caracteristici <a href="https://www.davx5.com/faq/tasks/advanced-task-features">nu sunt acceptate</a>.]]></string>
|
||||
<string name="intro_tasks_no_app_store">Nu există un magazin de aplicații disponibil</string>
|
||||
<string name="intro_tasks_dont_show">Nu am nevoie de suport pentru sarcini.*</string>
|
||||
<string name="intro_open_source_title">Software cu sursă deschisă</string>
|
||||
<string name="intro_open_source_text">Ne bucurăm că utilizezi %s, care este un software open-source. Dezvoltarea, întreținerea și suportul sunt o muncă grea. Ia în considerare contribuția (există mai multe moduri) sau o donație. Ar fi foarte apreciat!</string>
|
||||
<string name="intro_open_source_details">Cum să contribui/donezi</string>
|
||||
<string name="intro_open_source_dont_show">Nu afișa în viitorul apropiat</string>
|
||||
<string name="intro_next">Înainte</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_title">Permisiuni</string>
|
||||
<string name="permissions_text">%s necesită permisiuni pentru a funcționa corect.</string>
|
||||
<string name="permissions_all_title">Toate cele de mai jos</string>
|
||||
<string name="permissions_all_status_off">Utilizează aceasta pentru a activa toate funcțiile (recomandat)</string>
|
||||
<string name="permissions_all_status_on">Toate permisiunile sunt acordate</string>
|
||||
<string name="permissions_contacts_title">Permisiuni Contacte</string>
|
||||
<string name="permissions_contacts_status_off">Fără sincronizare de contacte (nu este recomandat)</string>
|
||||
<string name="permissions_contacts_status_on">Este posibilă sincronizarea contactelor</string>
|
||||
<string name="permissions_calendar_title">Permisiuni pentru calendar</string>
|
||||
<string name="permissions_calendar_status_off">Fără sincronizare calendar (nu este recomandat)</string>
|
||||
<string name="permissions_calendar_status_on">Sincronizarea calendarului este posibilă</string>
|
||||
<string name="permissions_notification_title">Permisiune de notificare</string>
|
||||
<string name="permissions_notification_status_off">Notificări dezactivate (nu este recomandat)</string>
|
||||
<string name="permissions_notification_status_on">Notificări activate</string>
|
||||
<string name="permissions_jtx_title">Permisiuni pentru jtx Board</string>
|
||||
<string name="permissions_opentasks_title">Permisiuni OpenTasks</string>
|
||||
<string name="permissions_tasksorg_title">Permisiuni pentru sarcini</string>
|
||||
<string name="permissions_tasks_status_off">Nicio sincronizare a sarcinilor</string>
|
||||
<string name="permissions_tasks_status_on">Este posibilă sincronizarea sarcinilor</string>
|
||||
<string name="permissions_autoreset_title">Păstrează permisiunile</string>
|
||||
<string name="permissions_autoreset_status_off">Permisiunile pot fi resetate automat (nu este recomandat)</string>
|
||||
<string name="permissions_autoreset_status_on">Permisiunile nu vor fi resetate automat</string>
|
||||
<string name="permissions_autoreset_instruction">Clic pe Permisiuni > debifează „Elimină permisiunile dacă aplicația nu este utilizată”</string>
|
||||
<string name="permissions_app_settings_hint">Dacă un comutator nu funcționează, utilizează setările/permisiunile aplicației.</string>
|
||||
<string name="permissions_app_settings">Setările aplicației</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<string name="wifi_permissions_label">Permisiuni SSID WiFi</string>
|
||||
<string name="wifi_permissions_intro">Pentru a putea accesa numele actual WiFi (SSID), trebuie îndeplinite următoarele condiții:</string>
|
||||
<string name="wifi_permissions_location_permission">Permisiune de locație precisă</string>
|
||||
<string name="wifi_permissions_location_permission_on">Permisiunea de locație acordată</string>
|
||||
<string name="wifi_permissions_location_permission_off">Permisiunea de locație refuzată</string>
|
||||
<string name="wifi_permissions_background_location_permission">Permisiunea de locație în fundal</string>
|
||||
<string name="wifi_permissions_background_location_permission_label">Permite tot timpul</string>
|
||||
<string name="wifi_permissions_background_location_permission_on">Permisiunea locației setată la: %s</string>
|
||||
<string name="wifi_permissions_background_location_permission_off">Permisiunea de locație nu este setată la: %s</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer">%s folosește datele locației (doar WiFi SSID) numai pentru a restricționa sincronizarea la un anumit SSID WiFi. Acest lucru se va întâmpla chiar și atunci când sincronizarea rulează în fundal.</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer2">Toate datele locației (doar WiFi SSID) sunt folosite doar local și nu sunt trimise nicăieri.</string>
|
||||
<string name="wifi_permissions_location_enabled">Locația este întotdeauna activată</string>
|
||||
<string name="wifi_permissions_location_enabled_on">Serviciul de localizare este activat</string>
|
||||
<string name="wifi_permissions_location_enabled_off">Serviciul de localizare este dezactivat</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_translations">Traduceri</string>
|
||||
<string name="about_libraries">Biblioteci</string>
|
||||
<string name="about_version">Versiune %1$s (%2$d)</string>
|
||||
<string name="about_copyright">© Ricki Hirner, Bernhard Stockmann (inginerie web bitfire GmbH) și contribuitori</string>
|
||||
<string name="about_license_info_no_warranty">Acest program vine cu ABSOLUT NICIO GARANȚIE. Este software gratuit și ești binevenit să îl redistribui în anumite condiții.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">Nu s-a putut crea fișierul jurnal</string>
|
||||
<string name="logging_notification_text">Acum se înregistrează toate activitățile %s</string>
|
||||
<string name="logging_notification_view_share">Vizualizare/distribuire</string>
|
||||
<string name="logging_notification_disable">Dezactivează</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">Adaptor de sincronizare CalDAV/CardDAV</string>
|
||||
<string name="navigation_drawer_about">Despre / Licență</string>
|
||||
<string name="navigation_drawer_beta_feedback">Feedback beta</string>
|
||||
<string name="install_browser">Instalează un browser web</string>
|
||||
<string name="navigation_drawer_settings">Setări</string>
|
||||
<string name="navigation_drawer_news_updates">Știri și actualizări</string>
|
||||
<string name="navigation_drawer_tools">Instrumente</string>
|
||||
<string name="navigation_drawer_external_links">Link-uri externe</string>
|
||||
<string name="navigation_drawer_website">Pagină web</string>
|
||||
<string name="navigation_drawer_manual">Manual</string>
|
||||
<string name="navigation_drawer_faq">Întrebări frecvente</string>
|
||||
<string name="navigation_drawer_community">Comunitate</string>
|
||||
<string name="navigation_drawer_support_project">Susține proiectul</string>
|
||||
<string name="navigation_drawer_contribute">Cum să contribui</string>
|
||||
<string name="navigation_drawer_privacy_policy">Politica de confidențialitate</string>
|
||||
<string name="account_list_no_notification_permission">Notificări dezactivate. Nu vei fi notificat despre erorile de sincronizare.</string>
|
||||
<string name="account_list_manage_connections">Gestionează conexiunile</string>
|
||||
<string name="account_list_datasaver_enabled">Economizorul de date este activat. Sincronizarea în fundal este restricționată.</string>
|
||||
<string name="account_list_manage_datasaver">Gestionează economizorul de date</string>
|
||||
<string name="account_list_battery_saver_enabled">Economisirea bateriei este activată. Sincronizarea poate fi restricționată.</string>
|
||||
<string name="account_list_manage_battery_saver">Gestionează economisirea bateriei</string>
|
||||
<string name="account_list_low_storage">Spațiu de depozitare redus. Android nu va sincroniza modificările locale imediat, ci în timpul următoarei sincronizări obișnuite.</string>
|
||||
<string name="account_list_manage_storage">Gestionează stocarea</string>
|
||||
<string name="account_list_welcome">Bun venit la DAVx⁵!</string>
|
||||
<string name="account_list_empty">Conectează-te la server și păstrează calendarele și contactele sincronizate.</string>
|
||||
<string name="accounts_sync_all">Sincronizează toate conturile</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">Detectarea serviciului a eșuat</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">Lista de colecții nu a putut fi actualizată</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<string name="foreground_service_notify_title">Rulează în prim-plan</string>
|
||||
<string name="foreground_service_notify_text">Pe unele dispozitive, acest lucru este necesar pentru sincronizarea automată.</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Setări</string>
|
||||
<string name="app_settings_debug">Depanare</string>
|
||||
<string name="app_settings_show_debug_info">Afișează informațiile de depanare</string>
|
||||
<string name="app_settings_show_debug_info_details">Vizualizează/partajează detaliile de configurare și jurnalele</string>
|
||||
<string name="app_settings_logging">Jurnalizare detaliată</string>
|
||||
<string name="app_settings_logging_on">Înregistrarea este activă. Poți vizualiza jurnalele ca parte a informațiilor de depanare.</string>
|
||||
<string name="app_settings_logging_off">Înregistrarea este dezactivată</string>
|
||||
<string name="app_settings_battery_optimization">Optimizarea bateriei</string>
|
||||
<string name="app_settings_battery_optimization_exempted">Aplicația este exclusă (recomandat)</string>
|
||||
<string name="app_settings_battery_optimization_optimized">Se aplică restricții pentru baterie (nu este recomandat)</string>
|
||||
<string name="app_settings_connection">Conexiune</string>
|
||||
<string name="app_settings_proxy">Tip proxy</string>
|
||||
<string-array name="app_settings_proxy_types">
|
||||
<item>Implicit</item>
|
||||
<item>Fără proxy</item>
|
||||
<item>HTTP</item>
|
||||
<item>SOCKS (pentru Orbot)</item>
|
||||
</string-array>
|
||||
<string name="app_settings_proxy_host">Nume gazdă proxy</string>
|
||||
<string name="app_settings_proxy_port">Port proxy</string>
|
||||
<string name="app_settings_security">Securitate</string>
|
||||
<string name="app_settings_security_app_permissions">Permisiunile aplicației</string>
|
||||
<string name="app_settings_security_app_permissions_summary">Examinează permisiunile necesare pentru sincronizare</string>
|
||||
<string name="app_settings_distrust_system_certs">Nu avea încredere în certificatele de sistem</string>
|
||||
<string name="app_settings_distrust_system_certs_on">CA de sistem și de utilizator nu vor fi de încredere</string>
|
||||
<string name="app_settings_distrust_system_certs_off">CA de sistem și de utilizator vor fi de încredere (recomandat)</string>
|
||||
<string name="app_settings_reset_certificates">Resetează certificatele de (ne)încredere</string>
|
||||
<string name="app_settings_reset_certificates_summary">Resetează încrederea tuturor certificatelor personalizate</string>
|
||||
<string name="app_settings_reset_certificates_success">Toate certificatele personalizate au fost șterse</string>
|
||||
<string name="app_settings_user_interface">Interfață de utilizator</string>
|
||||
<string name="app_settings_notification_settings">Setări de notificare</string>
|
||||
<string name="app_settings_notification_settings_summary">Gestionează canalele de notificare și setările acestora</string>
|
||||
<string name="app_settings_theme_title">Selectează tema</string>
|
||||
<string-array name="app_settings_theme_names">
|
||||
<item>Ca în sistem</item>
|
||||
<item>Luminoasă</item>
|
||||
<item>Întunecată</item>
|
||||
</string-array>
|
||||
<string name="app_settings_reset_hints">Resetează sugestiile</string>
|
||||
<string name="app_settings_reset_hints_summary">Reactivează sugestiile care au fost respinse anterior</string>
|
||||
<string name="app_settings_reset_hints_success">Toate sugestiile vor fi afișate din nou</string>
|
||||
<string name="app_settings_integration">Integrare</string>
|
||||
<string name="app_settings_tasks_provider">Aplicația de sarcini</string>
|
||||
<string name="app_settings_tasks_provider_none">Nu a fost găsită nicio aplicație de sarcini compatibilă</string>
|
||||
<string name="app_settings_unifiedpush">UnifiedPush (experimental)</string>
|
||||
<string name="app_settings_unifiedpush_no_endpoint">Niciun punct final configurat</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_missing_permissions">Sunt necesare permisiuni suplimentare pentru a sincroniza aceste colecții.</string>
|
||||
<string name="account_manage_permissions">Gestionează permisiunile</string>
|
||||
<string name="account_synchronize_now">Sincronizează acum</string>
|
||||
<string name="account_settings">Setările contului</string>
|
||||
<string name="account_rename">Redenumește contul</string>
|
||||
<string name="account_rename_new_name_description">Datele locale nesalvate pot fi respinse. Resincronizarea este necesară după redenumire.</string>
|
||||
<string name="account_rename_new_name">Nume cont nou</string>
|
||||
<string name="account_rename_rename">Redenumește</string>
|
||||
<string name="account_rename_exists_already">Numele contului este deja luat</string>
|
||||
<string name="account_rename_couldnt_rename">Nu s-a putut redenumi contul</string>
|
||||
<string name="account_delete">Șterge contul</string>
|
||||
<string name="account_delete_confirmation_title">Chiar ștergi contul?</string>
|
||||
<string name="account_delete_confirmation_text">Toate copiile locale ale agendelor, calendarelor și listelor de sarcini vor fi șterse.</string>
|
||||
<string name="account_synchronize_this_collection">sincronizează această colecție</string>
|
||||
<string name="account_read_only">numai pentru citire</string>
|
||||
<string name="account_calendar">calendar</string>
|
||||
<string name="account_contacts">contacte</string>
|
||||
<string name="account_journal">jurnal</string>
|
||||
<string name="account_task_list">sarcini</string>
|
||||
<string name="account_only_personal">Afișează numai personal</string>
|
||||
<string name="account_refresh_collections">Actualizează lista</string>
|
||||
<string name="account_webcal_external_app">Abonamentele Webcal pot fi sincronizate cu aplicații externe.</string>
|
||||
<string name="account_no_webcal_handler_found">Nu a fost găsită nicio aplicație compatibilă cu Webcal</string>
|
||||
<string name="account_install_icsx5">Instalează ICSx⁵</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Adaugă contul</string>
|
||||
<string name="login_privacy_hint"><![CDATA[Toate datele vor fi transferate numai între server și dispozitiv. %1$s nu le voi trimite altundeva. Vezi <a href="%2$s">Politica de confidențialitate</a>.]]></string>
|
||||
<string name="login_generic_login">Autentificare generică</string>
|
||||
<string name="login_provider_login">Autentificare specifică furnizorului</string>
|
||||
<string name="login_continue">Continuă</string>
|
||||
<string name="login_login">Autentificare</string>
|
||||
<string name="login_type_email">Conectează-te cu adresa de e-mail</string>
|
||||
<string name="login_email_address">Adresa de e-mail</string>
|
||||
<string name="login_email_address_error">Este necesară o adresă de e-mail validă</string>
|
||||
<string name="login_email_address_info"><![CDATA[Domeniul de e-mail este folosit ca URL de bază. <a href="%s">Serviciile sunt descoperite</a> folosind înregistrări DNS și adrese URL bine-cunoscute.]]></string>
|
||||
<string name="login_password">Parolă</string>
|
||||
<string name="login_password_hide">Ascunde parola</string>
|
||||
<string name="login_password_show">Afișează parola</string>
|
||||
<string name="login_password_optional">Parolă*</string>
|
||||
<string name="login_type_url">Conecteează-te cu adresa URL și numele de utilizator</string>
|
||||
<string name="login_user_name">Nume de utilizator</string>
|
||||
<string name="login_user_name_optional">Nume de utilizator*</string>
|
||||
<string name="login_base_url">Adresa URL de bază</string>
|
||||
<string name="login_base_url_info"><![CDATA[Adresa URL de bază va fi verificată direct, dar <a href="%s">serviciile sunt de asemenea descoperite</a> folosind înregistrări DNS și adrese URL bine-cunoscute.]]></string>
|
||||
<string name="login_select_certificate">Selectează certificatul</string>
|
||||
<string name="login_add_account">Adaugă contul</string>
|
||||
<string name="login_account_name">Nume de cont</string>
|
||||
<string name="login_account_avoid_apostrophe">Utilizarea apostrofelor (\') pare să cauzeze probleme pe unele dispozitive.</string>
|
||||
<string name="login_account_name_info">Utilizează adresa de e-mail ca nume de cont, deoarece Android va folosi numele contului ca câmp ORGANIZATOR pentru evenimentele pe care le creezi. Nu poți avea două conturi cu același nume.</string>
|
||||
<string name="login_account_contact_group_method">Metoda de grupare a contactelor:</string>
|
||||
<string name="login_account_name_required">Numele contului este necesar</string>
|
||||
<string name="login_account_name_already_taken">Numele contului este deja luat</string>
|
||||
<string name="login_account_not_added">Contul nu a putut fi adăugat</string>
|
||||
<string name="login_finish">Finalizează</string>
|
||||
<string name="login_type_advanced">Autentificare avansată</string>
|
||||
<string name="login_no_client_certificate_optional">Fără certificat de client*</string>
|
||||
<string name="login_client_certificate_selected">Certificat de client: %s</string>
|
||||
<string name="login_no_certificate_found">Nu a fost găsit niciun certificat</string>
|
||||
<string name="login_install_certificate">Instalare certificat</string>
|
||||
<string name="login_type_google">Contacte Google / Calendar</string>
|
||||
<string name="login_google_see_tested_with">Consultă pagina noastră „Testat cu Google” pentru informații actualizate.</string>
|
||||
<string name="login_google_unexpected_warnings">Este posibil să ai avertismente neașteptate și/sau să fii nevoit să creezi propriul ID de client.</string>
|
||||
<string name="login_google_account">Cont Google</string>
|
||||
<string name="login_google">Conectează-te cu Google</string>
|
||||
<string name="login_google_client_id">ID client (opțional)</string>
|
||||
<string name="login_google_client_privacy_policy"><![CDATA[%1$s transferă datele din Agendă Google și din Calendar numai pentru sincronizare cu acest dispozitiv. Vezi <a href="%2$s">Politica de confidențialitate</a> pentru detalii.]]></string>
|
||||
<string name="login_google_client_limited_use"><![CDATA[%1$s respectă <a href="%2$s">Politica privind datele utilizatorilor serviciilor API Google</a>, inclusiv cerințele de utilizare limitată.]]></string>
|
||||
<string name="login_oauth_couldnt_obtain_auth_code">Nu s-a putut obține codul de autorizare</string>
|
||||
<string name="login_type_nextcloud">Nextcloud</string>
|
||||
<string name="login_nextcloud_login_with_nextcloud">Conectare cu Nextcloud</string>
|
||||
<string name="login_nextcloud_login_flow_text">Aceasta va porni fluxul de conectare Nextcloud într-un browser web.</string>
|
||||
<string name="login_nextcloud_login_flow_server_address">Adresa serverului Nextcloud</string>
|
||||
<string name="login_nextcloud_login_flow_sign_in">Conectare</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_url">Nu s-a putut obține adresa URL de conectare</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_data">Nu s-au putut obține datele de conectare</string>
|
||||
<string name="login_configuration_detection">Detectarea configurației</string>
|
||||
<string name="login_querying_server">Se interoghează serverul…</string>
|
||||
<string name="login_no_service">Nu s-a putut găsi serviciul CalDAV sau CardDAV.</string>
|
||||
<string name="login_no_service_info">Adresa URL de bază nu pare să fie o adresă URL CalDAV/CardDAV accesibilă, iar detectarea serviciului nu a avut succes.</string>
|
||||
<string name="login_see_tested_services"><![CDATA[Consultă manualul furnizorului de servicii, <a href="%s">lista de servicii testate</a> și adresele lor URL de bază.]]></string>
|
||||
<string name="login_check_credentials">Verifică, de asemenea, și autentificarea (de obicei, numele de utilizator și parola).</string>
|
||||
<string name="login_logs_available">Informații tehnice suplimentare sunt disponibile în jurnale.</string>
|
||||
<string name="login_view_logs">Vezi jurnalele</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">Sincronizare</string>
|
||||
<string name="settings_sync_interval_contacts">Interval de sincronizare a contactelor</string>
|
||||
<string name="settings_sync_summary_manually">Doar manual</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">La fiecare %d minute + imediat la modificări locale</string>
|
||||
<string name="settings_sync_interval_calendars">Interval de sincronizare a calendarelor</string>
|
||||
<string name="settings_sync_interval_tasks">Interval de sincronizare a sarcinilor</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>Doar manual</item>
|
||||
<item>La fiecare 15 minute</item>
|
||||
<item>La fiecare 30 de minute</item>
|
||||
<item>La fiecare oră</item>
|
||||
<item>La fiecare 2 ore</item>
|
||||
<item>La fiecare 4 ore</item>
|
||||
<item>O dată pe zi</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">Sincronizare numai prin WiFi</string>
|
||||
<string name="settings_sync_wifi_only_on">Sincronizarea este limitată la conexiunile WiFi</string>
|
||||
<string name="settings_sync_wifi_only_off">Tipul de conexiune nu este luat în considerare</string>
|
||||
<string name="settings_sync_wifi_only_ssids">Restricție SSID WiFi</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Se va sincroniza numai prin %s</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Toate conexiunile WiFi vor fi utilizate</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">Nume separate prin virgulă (SSID) ale rețelelor WiFi permise (lasă necompletat pentru toate)</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_required">Restricția SSID WiFi necesită setări suplimentare</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">Gestionează</string>
|
||||
<string name="settings_ignore_vpns">VPN necesită internetul de bază</string>
|
||||
<string name="settings_ignore_vpns_on">VPN fără conexiune validată la Internet nu este suficient pentru a rula sincronizarea (recomandat)</string>
|
||||
<string name="settings_ignore_vpns_off">VPN fără conexiune validată la Internet este suficient pentru a rula sincronizarea</string>
|
||||
<string name="settings_authentication">Autentificare</string>
|
||||
<string name="settings_username">Nume de utilizator</string>
|
||||
<string name="settings_password">Parolă</string>
|
||||
<string name="settings_new_password">Parolă nouă</string>
|
||||
<string name="settings_password_summary">Actualizează parola în funcție de server.</string>
|
||||
<string name="settings_certificate_alias">Certificat de client</string>
|
||||
<string name="settings_certificate_alias_empty">Niciun certificat disponibil sau selectat</string>
|
||||
<string name="settings_certificate_install">Instalare certificat</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Limită de timp pentru evenimentele din trecut</string>
|
||||
<string name="settings_sync_time_range_past_none">Toate evenimentele vor fi sincronizate</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Evenimentele cu mai mult de o zi în trecut vor fi ignorate</item>
|
||||
<item quantity="few">Evenimentele cu peste %d zile în trecut vor fi ignorate</item>
|
||||
<item quantity="other">Evenimentele cu peste %d zile în trecut vor fi ignorate</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Evenimentele care depășesc acest număr de zile în trecut vor fi ignorate (poate fi 0). Lasă necompletat pentru a sincroniza toate evenimentele.</string>
|
||||
<string name="settings_default_alarm">Memento implicit</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="one">Memento implicit cu un minut înainte de eveniment</item>
|
||||
<item quantity="few">Memento implicit cu %d minute înainte de eveniment</item>
|
||||
<item quantity="other">Memento implicit cu %d minute înainte de eveniment</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">Nu sunt create mementouri implicite</string>
|
||||
<string name="settings_default_alarm_message">Dacă vor fi create memento-uri implicite pentru evenimente fără memento: numărul dorit de minute înainte de eveniment. Lasă necompletat pentru a dezactiva memento-urile implicite.</string>
|
||||
<string name="settings_manage_calendar_colors">Gestionează culorile calendarului</string>
|
||||
<string name="settings_manage_calendar_colors_on">Culorile calendarului sunt resetate la fiecare sincronizare</string>
|
||||
<string name="settings_manage_calendar_colors_off">Culorile calendarului pot fi setate de alte aplicații</string>
|
||||
<string name="settings_event_colors">Suport pentru culoarea evenimentului</string>
|
||||
<string name="settings_event_colors_on">Culorile evenimentelor sunt sincronizate</string>
|
||||
<string name="settings_event_colors_off">Culorile evenimentelor nu sunt sincronizate</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Metoda de grupare a contactelor</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>Grupurile sunt vCard-uri separate</item>
|
||||
<item>Grupurile sunt categorii per-contact</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">Creează agendă de adrese</string>
|
||||
<string name="create_addressbook_maybe_not_supported">Crearea agendei prin CardDAV poate să nu fie acceptată de server.</string>
|
||||
<string name="create_calendar">Creează un calendar</string>
|
||||
<string name="create_calendar_time_zone_optional">Fus orar implicit*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">Posibile intrări din calendar</string>
|
||||
<string name="create_calendar_type_vevent">Evenimente</string>
|
||||
<string name="create_calendar_type_vtodo">Sarcini</string>
|
||||
<string name="create_calendar_type_vjournal">Note/jurnal</string>
|
||||
<string name="create_calendar_maybe_not_supported">Crearea calendarului prin CalDAV poate să nu fie acceptată de server.</string>
|
||||
<string name="create_collection_color">Culoare</string>
|
||||
<string name="create_collection_display_name">Titlu</string>
|
||||
<string name="create_collection_home_set">Locația de stocare</string>
|
||||
<string name="create_collection_description_optional">Descriere*</string>
|
||||
<string name="create_collection_create">Crează</string>
|
||||
<string name="create_collection_optional">* opțional</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">Șterge colecția</string>
|
||||
<string name="collection_delete_warning">Această colecție (%s) și toate datele sale vor fi șterse definitiv, atât local, cât și de pe server.</string>
|
||||
<string name="collection_synchronization">Sincronizare</string>
|
||||
<string name="collection_synchronization_on">Sincronizarea este activată</string>
|
||||
<string name="collection_synchronization_off">Sincronizarea este dezactivată</string>
|
||||
<string name="collection_read_only">Numai citire</string>
|
||||
<string name="collection_read_only_by_server">Numai citire (de pe server)</string>
|
||||
<string name="collection_read_only_by_setting">Numai citire (după politică)</string>
|
||||
<string name="collection_read_only_forced">Numai citire (doar local)</string>
|
||||
<string name="collection_read_write">Citire/scriere</string>
|
||||
<string name="collection_title">Titlu</string>
|
||||
<string name="collection_description">Descriere</string>
|
||||
<string name="collection_owner">Proprietar</string>
|
||||
<string name="collection_push_support">Suport Push</string>
|
||||
<string name="collection_push_web_push">Serverul informează despre suportul Push</string>
|
||||
<string name="collection_push_subscribed_at">Abonat la %1$s, expiră la %2$s</string>
|
||||
<string name="collection_last_sync">Ultima sincronizare (%s)</string>
|
||||
<string name="collection_url">Adresă (URL)</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Informații de depanare</string>
|
||||
<string name="debug_info_archive_caption">Arhivă ZIP</string>
|
||||
<string name="debug_info_archive_subtitle">Conține informații de depanare și jurnale</string>
|
||||
<string name="debug_info_archive_text">Partajează arhiva pentru a o transfera pe un computer, pentru a o trimite prin e-mail sau pentru a o atașa la un bilet de asistență.</string>
|
||||
<string name="debug_info_archive_share">Partajează arhiva</string>
|
||||
<string name="debug_info_attached">Informații de depanare atașate la acest mesaj (necesită suport pentru atașamentele aplicației care primește).</string>
|
||||
<string name="debug_info_http_error">Eroare HTTP</string>
|
||||
<string name="debug_info_server_error">Eroare de server</string>
|
||||
<string name="debug_info_webdav_error">Eroare WebDAV</string>
|
||||
<string name="debug_info_io_error">Eroare I/O</string>
|
||||
<string name="debug_info_http_403_description">Solicitarea a fost respinsă. Verifică resursele implicate și informațiile de depanare pentru detalii.</string>
|
||||
<string name="debug_info_http_404_description">Resursa solicitată nu mai există (mai mult). Verifică resursele implicate și informațiile de depanare pentru detalii.</string>
|
||||
<string name="debug_info_http_5xx_description">A apărut o problemă la nivelul serverului. Contactează asistența serverului.</string>
|
||||
<string name="debug_info_unexpected_error">A apărut o eroare neașteptată. Vezi informațiile de depanare pentru detalii.</string>
|
||||
<string name="debug_info_view_details">Vezi detaliile</string>
|
||||
<string name="debug_info_subtitle">Au fost colectate informații de depanare</string>
|
||||
<string name="debug_info_involved_caption">Resurse implicate</string>
|
||||
<string name="debug_info_involved_subtitle">Legat de problema</string>
|
||||
<string name="debug_info_involved_remote">Resursa de la distanță:</string>
|
||||
<string name="debug_info_involved_local">Resursa locală:</string>
|
||||
<string name="debug_info_logs_caption">Jurnale</string>
|
||||
<string name="debug_info_logs_subtitle">Jurnalele detaliate sunt disponibile</string>
|
||||
<string name="debug_info_logs_view">Vezi jurnalele</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">A avut loc o eroare.</string>
|
||||
<string name="exception_httpexception">A apărut o eroare HTTP.</string>
|
||||
<string name="exception_ioexception">A apărut o eroare I/O.</string>
|
||||
<string name="exception_show_details">Afișează detaliile</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_mounts_title">Montări WebDAV</string>
|
||||
<string name="webdav_mounts_quota_used_available">Cotă utilizată: %1$s / disponibilă: %2$s</string>
|
||||
<string name="webdav_mounts_share_content">Partajează conținutul</string>
|
||||
<string name="webdav_mounts_unmount">Demontează</string>
|
||||
<string name="webdav_add_mount_title">Adaugă o montare WebDAV</string>
|
||||
<string name="webdav_mounts_empty">Accesează direct fișierele din cloud adăugând o montare WebDAV!</string>
|
||||
<string name="webdav_add_mount_empty_more_info"><![CDATA[Vezi manualul pentru a afla <a href="%1$s">cum funcționează montările WebDAV</a>.</string>]]></string>
|
||||
<string name="webdav_add_mount_display_name">Numele afișat</string>
|
||||
<string name="webdav_add_mount_url">URL WebDAV</string>
|
||||
<string name="webdav_add_mount_url_invalid">URL greșit</string>
|
||||
<string name="webdav_add_mount_authentication">Autentificare (opțional)</string>
|
||||
<string name="webdav_add_mount_username">Nume de utilizator</string>
|
||||
<string name="webdav_add_mount_password">Parolă</string>
|
||||
<string name="webdav_add_mount_add">Adaugă montare</string>
|
||||
<string name="webdav_add_mount_no_support">Niciun serviciu WebDAV la această adresă URL</string>
|
||||
<string name="webdav_remove_mount_title">Elimină punctul de montare</string>
|
||||
<string name="webdav_remove_mount_text">Detaliile conexiunii se vor pierde, dar niciun fișier nu va fi șters.</string>
|
||||
<string name="webdav_notification_access">Se accesează fișierul WebDAV</string>
|
||||
<string name="webdav_notification_download">Se descarcă fișierul WebDAV</string>
|
||||
<string name="webdav_notification_upload">Se actualizează fișierul WebDAV</string>
|
||||
<string name="webdav_provider_root_title">Montare WebDAV</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">Permisiuni DAVx⁵</string>
|
||||
<string name="sync_error_permissions_text">Sunt necesare permisiuni suplimentare</string>
|
||||
<string name="sync_error_tasks_too_old">%s prea vechi</string>
|
||||
<string name="sync_error_tasks_required_version">Versiunea minimă necesară: %1$s</string>
|
||||
<string name="sync_error_authentication_failed">Autentificare eșuată (verifică datele de conectare)</string>
|
||||
<string name="sync_error_io">Eroare de rețea sau I/O – %s</string>
|
||||
<string name="sync_error_http_dav">Eroare de server HTTP – %s</string>
|
||||
<string name="sync_error_local_storage">Eroare de stocare locală – %s</string>
|
||||
<string name="sync_error_retry_limit_reached">Eroare soft (încercări maxime atinse)</string>
|
||||
<string name="sync_error_view_item">Vezi elementul</string>
|
||||
<string name="sync_invalid_contact">S-a primit contact nevalid de la server</string>
|
||||
<string name="sync_invalid_event">S-a primit eveniment nevalid de la server</string>
|
||||
<string name="sync_invalid_task">S-a primit sarcină nevalidă de la server</string>
|
||||
<string name="sync_invalid_resources_ignoring">Ignorarea uneia sau mai multor resurse nevalide</string>
|
||||
<string name="sync_notification_pending_push_title">Sincronizare în așteptare</string>
|
||||
<string name="sync_notification_pending_push_message">Datele de la distanță s-au schimbat</string>
|
||||
<!--widgets-->
|
||||
<string name="widget_sync_all">Sincronizează tot</string>
|
||||
<string name="widget_sync_all_accounts">Sincronizează toate conturile</string>
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,212 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">帳號(已)不存在</string>
|
||||
<string name="account_title_address_book">DAVx⁵ 通訊錄</string>
|
||||
<string name="field_required">此為必填欄位</string>
|
||||
<string name="help">幫助</string>
|
||||
<string name="share">分享</string>
|
||||
<string name="database_destructive_migration_title">資料庫損毀</string>
|
||||
<string name="database_destructive_migration_text">所有帳號已在本地刪除</string>
|
||||
<string name="notification_channel_debugging">除錯</string>
|
||||
<string name="notification_channel_general">其他重要訊息</string>
|
||||
<string name="notification_channel_status">低優先的狀態訊息</string>
|
||||
<string name="notification_channel_sync">同步</string>
|
||||
<string name="notification_channel_sync_errors">同步錯誤</string>
|
||||
<string name="notification_channel_sync_errors_desc">導致同步停止的嚴重錯誤,如異常的伺服器回應</string>
|
||||
<string name="notification_channel_sync_warnings">同步警告</string>
|
||||
<string name="notification_channel_sync_warnings_desc">可忽略的同步問題,比如一些無效檔案</string>
|
||||
<string name="notification_channel_sync_io_errors">網路和輸入輸出錯誤</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">逾時、連線問題等等(通常為暫時性)</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">您的資料,您的選擇</string>
|
||||
<string name="intro_slogan2">權力在握</string>
|
||||
<string name="intro_battery_title">定期同步間隔</string>
|
||||
<string name="intro_battery_text">為了定期進行同步,必須允許 %s 在背景運行,否則 Android 可能會隨時暫停同步。</string>
|
||||
<string name="intro_battery_dont_show">我不需要定期同步間隔*</string>
|
||||
<string name="intro_autostart_title">%s 相容性</string>
|
||||
<string name="intro_autostart_text">該裝置可能阻擋了同步,若您受到影響,只能手動解決。</string>
|
||||
<string name="intro_autostart_dont_show">所需設定已完成,不用再提醒我*</string>
|
||||
<string name="intro_leave_unchecked">* 取消勾選則稍後會再次提醒,可於設定中重置 / %s</string>
|
||||
<string name="intro_more_info">更多資訊</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[支持任務、日記及筆記同步]]></string>
|
||||
<string name="intro_tasks_title">待辦事項支援</string>
|
||||
<string name="intro_tasks_text1">如果你的服務器支持任務,它們可以與支援任務的app同步:</string>
|
||||
<string name="intro_tasks_no_app_store">沒有應用商店可用</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_contacts_title">通訊錄權限</string>
|
||||
<string name="permissions_calendar_title">行事曆權限</string>
|
||||
<string name="permissions_opentasks_title">OpenTasks 權限</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<!--AboutActivity-->
|
||||
<string name="about_libraries">函式庫</string>
|
||||
<string name="about_version">版本號 %1$s(%2$d)</string>
|
||||
<string name="about_license_info_no_warranty">我們「完全不保證」本程式無瑕疵。這是個自由軟體,歡迎您在符合公用授權條款的情況下任意散布它。</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">無法創建事項記錄文檔</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV 同步器</string>
|
||||
<string name="navigation_drawer_about">關於我們 / 授權條款</string>
|
||||
<string name="navigation_drawer_beta_feedback">為測試版本給回饋意見</string>
|
||||
<string name="install_browser">請安裝一個瀏覽器程式</string>
|
||||
<string name="navigation_drawer_settings">設定</string>
|
||||
<string name="navigation_drawer_news_updates">新聞 & 更新</string>
|
||||
<string name="navigation_drawer_external_links">外部連結</string>
|
||||
<string name="navigation_drawer_website">我們的網站</string>
|
||||
<string name="navigation_drawer_manual">使用説明書</string>
|
||||
<string name="navigation_drawer_faq">常見問答</string>
|
||||
<string name="navigation_drawer_privacy_policy">隱私權政策</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">未發現遠端服務</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">無法更新清單</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">設定</string>
|
||||
<string name="app_settings_debug">除錯</string>
|
||||
<string name="app_settings_show_debug_info">顯示除錯訊息</string>
|
||||
<string name="app_settings_logging">詳細除錯記錄</string>
|
||||
<string name="app_settings_logging_off">日誌記錄已停用</string>
|
||||
<string name="app_settings_battery_optimization">電池最佳化</string>
|
||||
<string name="app_settings_connection">網路連線</string>
|
||||
<string name="app_settings_security">安全性</string>
|
||||
<string name="app_settings_distrust_system_certs">不信任系統憑證</string>
|
||||
<string name="app_settings_distrust_system_certs_on">系統憑證和使用者自訂憑證將不被信任</string>
|
||||
<string name="app_settings_distrust_system_certs_off">系統憑證和使用者自訂憑證將被信任 (推薦設定)</string>
|
||||
<string name="app_settings_reset_certificates">重新開啟之前關閉的提示</string>
|
||||
<string name="app_settings_reset_certificates_summary">重設對所有自訂憑證的信任</string>
|
||||
<string name="app_settings_reset_certificates_success">所有自訂憑證已清除</string>
|
||||
<string name="app_settings_user_interface">使用介面</string>
|
||||
<string name="app_settings_notification_settings">通知設定</string>
|
||||
<string name="app_settings_notification_settings_summary">管理通知頻道和設定</string>
|
||||
<string name="app_settings_reset_hints">重新開啟提示</string>
|
||||
<string name="app_settings_reset_hints_summary">重新啟用之前取消的提示</string>
|
||||
<string name="app_settings_reset_hints_success">所有提示將再次顯示</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV聯絡人檔案</string>
|
||||
<string name="account_caldav">CalDav行事曆檔案</string>
|
||||
<string name="account_webcal">Webcal網際網絡行事曆</string>
|
||||
<string name="account_synchronize_now">立即同步</string>
|
||||
<string name="account_settings">帳號設定</string>
|
||||
<string name="account_rename">重新命名帳號</string>
|
||||
<string name="account_rename_rename">重新命名</string>
|
||||
<string name="account_rename_exists_already">這個賬號名稱已經被取過了</string>
|
||||
<string name="account_rename_couldnt_rename">無法重新命名帳號</string>
|
||||
<string name="account_delete">刪除帳號</string>
|
||||
<string name="account_delete_confirmation_title">確定要刪除帳號?</string>
|
||||
<string name="account_delete_confirmation_text">這台裝置上這個帳號的通訊錄、行事曆和工作清單將被刪除。</string>
|
||||
<string name="account_synchronize_this_collection">同步這個行事曆或工作清單</string>
|
||||
<string name="account_read_only">唯讀</string>
|
||||
<string name="account_calendar">行事曆</string>
|
||||
<string name="account_only_personal">只顯示個人</string>
|
||||
<string name="account_no_webcal_handler_found">未找到支援Webcal的APP</string>
|
||||
<string name="account_install_icsx5">安裝ICSx⁵</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">新增帳號</string>
|
||||
<string name="login_login">登入</string>
|
||||
<string name="login_type_email">用 Email 地址登入</string>
|
||||
<string name="login_email_address">Email 地址</string>
|
||||
<string name="login_email_address_error">請輸入有效的 Email 地址</string>
|
||||
<string name="login_password">密碼</string>
|
||||
<string name="login_type_url">用網址和帳號登入</string>
|
||||
<string name="login_user_name">使用者帳號</string>
|
||||
<string name="login_base_url">根 URL</string>
|
||||
<string name="login_select_certificate">點選憑證</string>
|
||||
<string name="login_add_account">新增帳號</string>
|
||||
<string name="login_account_name">帳號名稱</string>
|
||||
<string name="login_account_name_info">使用 Email 地址當作裝置上的帳號顯示名稱,因為當您在行事曆創建活動時,Android 會把帳號顯示名稱放到「活動發起人」欄位。兩個帳號不能有相同的名稱。</string>
|
||||
<string name="login_account_contact_group_method">聯絡人群組的儲存格式</string>
|
||||
<string name="login_account_name_required">需要帳號名稱</string>
|
||||
<string name="login_account_name_already_taken">這個賬號名稱已經被取過了</string>
|
||||
<string name="login_configuration_detection">設定錯誤</string>
|
||||
<string name="login_querying_server">請稍待,正在詢問伺服器…</string>
|
||||
<string name="login_no_service">找不到 CalDAV 或 CardDAV 服務。</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">同步設定</string>
|
||||
<string name="settings_sync_interval_contacts">聯絡人同步間隔</string>
|
||||
<string name="settings_sync_summary_manually">只手動同步</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">每 %d 分鐘,以及在本裝置上修改時</string>
|
||||
<string name="settings_sync_interval_calendars">行事曆同步間隔</string>
|
||||
<string name="settings_sync_interval_tasks">待辦事項同步間隔</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>僅手動</item>
|
||||
<item>每15分鐘自動</item>
|
||||
<item>每30分鐘自動</item>
|
||||
<item>每小時自動</item>
|
||||
<item>每2小時自動</item>
|
||||
<item>每4小時自動</item>
|
||||
<item>每天自動</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">只用 WiFi 同步</string>
|
||||
<string name="settings_sync_wifi_only_on">只於 WiFi 連線時同步</string>
|
||||
<string name="settings_sync_wifi_only_off">任何網路連線都可使用</string>
|
||||
<string name="settings_sync_wifi_only_ssids">限用特定 WiFi SSID</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">只在%s連線時同步</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">所有 WiFi 連線都可以使用</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">使用逗號分割的名稱 (SSIDs) 表示的 WiFi 連線(留空則代表全部)</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">管理</string>
|
||||
<string name="settings_authentication">認證</string>
|
||||
<string name="settings_username">使用者帳號</string>
|
||||
<string name="settings_password">密碼</string>
|
||||
<string name="settings_password_summary">您在伺服器上使用中的密碼</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">過去活動的時間限制</string>
|
||||
<string name="settings_sync_time_range_past_none">將會同步所有活動</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="other">%d 天之前的活動會被忽略</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">此天數前的活動將會被忽略(可設為零),若留空則同步所有活動</string>
|
||||
<string name="settings_default_alarm">預設提醒</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="other">預設在活動前 %d 分鐘提醒</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">未設定預設提醒</string>
|
||||
<string name="settings_default_alarm_message">當沒有提醒的活動需要加入預設提醒時,活動開始前多少分鐘出發提醒。留空則停用預設提醒。</string>
|
||||
<string name="settings_manage_calendar_colors">管理行事曆的顏色</string>
|
||||
<string name="settings_event_colors">設定活動的顔色</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">聯絡人群組的儲存格式</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>群組存成額外的 VCard 檔案</item>
|
||||
<item>群組存成每個聯絡人的分類屬性</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">建立通訊錄</string>
|
||||
<string name="create_calendar">建立行事曆</string>
|
||||
<string name="create_calendar_type">可使用的行事曆項目</string>
|
||||
<string name="create_calendar_type_vevent">活動</string>
|
||||
<string name="create_calendar_type_vtodo">事務</string>
|
||||
<string name="create_calendar_type_vjournal">筆記/日誌</string>
|
||||
<string name="create_collection_color">顔色</string>
|
||||
<string name="create_collection_display_name">標題</string>
|
||||
<string name="create_collection_home_set">存儲位置</string>
|
||||
<string name="create_collection_create">建立</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">刪除行事曆或工作清單</string>
|
||||
<string name="collection_synchronization">同步</string>
|
||||
<string name="collection_title">標題</string>
|
||||
<string name="collection_description">描述</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">除錯訊息</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">發生錯誤</string>
|
||||
<string name="exception_httpexception">HTTP 發生錯誤</string>
|
||||
<string name="exception_ioexception">讀寫錯誤</string>
|
||||
<string name="exception_show_details">顯示細節</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_add_mount_username">使用者帳號</string>
|
||||
<string name="webdav_add_mount_password">密碼</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">DAVx⁵ 權限</string>
|
||||
<string name="sync_error_permissions_text">需要額外的權限</string>
|
||||
<string name="sync_error_authentication_failed">鑒權失敗(你需要檢查登錄憑證)</string>
|
||||
<string name="sync_error_io">網際網絡或者輸入輸出錯誤——%s</string>
|
||||
<string name="sync_error_http_dav">HTTP伺服器錯誤——%s</string>
|
||||
<string name="sync_error_local_storage">資料庫錯誤——%s</string>
|
||||
<string name="sync_error_view_item">查閲項目</string>
|
||||
<string name="sync_invalid_contact">收到了無效的聯絡人</string>
|
||||
<string name="sync_invalid_event">收到了無效的事件</string>
|
||||
<string name="sync_invalid_task">收到了無效的任務</string>
|
||||
<string name="sync_invalid_resources_ignoring">略過了一個或多個無效的資料</string>
|
||||
<!--widgets-->
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,451 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="account_invalid">账户(已)不存在</string>
|
||||
<string name="account_title_address_book">DAVx⁵ 通讯录</string>
|
||||
<string name="account_prefs_use_app">别在这里更改账户!请直接使用应用管理账户。</string>
|
||||
<string name="dialog_delete">删除</string>
|
||||
<string name="dialog_remove">删除</string>
|
||||
<string name="dialog_deny">取消</string>
|
||||
<string name="field_required">此字段是必填项</string>
|
||||
<string name="help">帮助</string>
|
||||
<string name="navigate_up">向上导航</string>
|
||||
<string name="optional_label">* 可选</string>
|
||||
<string name="options_menu">选项菜单</string>
|
||||
<string name="share">分享</string>
|
||||
<string name="sync_started">同步已启动/已加入队列</string>
|
||||
<string name="database_destructive_migration_title">数据库损坏</string>
|
||||
<string name="database_destructive_migration_text">所有帐户已在本地删除。</string>
|
||||
<string name="notification_channel_debugging">调试</string>
|
||||
<string name="notification_channel_general">其它重要消息</string>
|
||||
<string name="notification_channel_status">低优先级状态消息</string>
|
||||
<string name="notification_channel_sync">同步</string>
|
||||
<string name="notification_channel_sync_errors">同步错误</string>
|
||||
<string name="notification_channel_sync_errors_desc">导致同步停止的重要错误,如异常的服务器响应</string>
|
||||
<string name="notification_channel_sync_warnings">同步警告</string>
|
||||
<string name="notification_channel_sync_warnings_desc">不重要的同步问题,如某文件无效</string>
|
||||
<string name="notification_channel_sync_io_errors">网络或 I/O 错误</string>
|
||||
<string name="notification_channel_sync_io_errors_desc">超时、连接异常等问题(通常是临时错误)</string>
|
||||
<!--IntroActivity-->
|
||||
<string name="intro_slogan1">您的数据。您的选择。</string>
|
||||
<string name="intro_slogan2">获得控制。</string>
|
||||
<string name="intro_battery_title">定期同步间隔</string>
|
||||
<string name="intro_battery_text">为了定期进行同步,必须允许%s在后台运行。否则,Android可能会随时暂停同步。</string>
|
||||
<string name="intro_battery_dont_show">我不需要定期的同步。*</string>
|
||||
<string name="intro_autostart_title">%s兼容性</string>
|
||||
<string name="intro_autostart_text">该设备可能会阻止同步。如果您受到影响,则只能手动解决。</string>
|
||||
<string name="intro_autostart_dont_show">我已完成所需的设置。不再提醒我。*</string>
|
||||
<string name="intro_leave_unchecked">*取消选中以供稍后提醒。可以在应用设置中重置/%s。</string>
|
||||
<string name="intro_more_info">更多信息</string>
|
||||
<string name="intro_tasks_jtx">jtx Board</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[支持任务、日记和笔记同步]]></string>
|
||||
<string name="intro_tasks_title">任务支持</string>
|
||||
<string name="intro_tasks_text1">如果你的服务器支持任务,它们可以通过一个受支持的任务应用进行同步:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks </string>
|
||||
<string name="intro_tasks_opentasks_info">似乎已不再开发 — 不推荐</string>
|
||||
<string name="intro_tasks_tasks_org">Tasks.org</string>
|
||||
<string name="intro_tasks_tasks_org_info"><![CDATA[某些功能 <a href="https://www.davx5.com/faq/tasks/advanced-task-features">不被支持</a>。]]></string>
|
||||
<string name="intro_tasks_no_app_store">没有可用的应用商店</string>
|
||||
<string name="intro_tasks_dont_show">我不需要任务支持。*</string>
|
||||
<string name="intro_open_source_title">开源软件</string>
|
||||
<string name="intro_open_source_text">我们很高兴您使用 %s 开源软件。开发、维护和支持是艰苦的工作。请考虑通过多种方式提供贡献或捐款。不胜感激!</string>
|
||||
<string name="intro_open_source_details">如何贡献或捐款</string>
|
||||
<string name="intro_open_source_dont_show">以后不需要显示</string>
|
||||
<string name="intro_next">继续</string>
|
||||
<!--PermissionsActivity-->
|
||||
<string name="permissions_title">权限</string>
|
||||
<string name="permissions_text">%s需要权限才能正常工作</string>
|
||||
<string name="permissions_all_title">以下所有</string>
|
||||
<string name="permissions_all_status_off">使用它来启用所有特性 (推荐)</string>
|
||||
<string name="permissions_all_status_on">已授予全部权限</string>
|
||||
<string name="permissions_contacts_title">联系人权限</string>
|
||||
<string name="permissions_contacts_status_off">无联系人同步(不推荐)</string>
|
||||
<string name="permissions_contacts_status_on">可同步联系人</string>
|
||||
<string name="permissions_calendar_title">日历权限</string>
|
||||
<string name="permissions_calendar_status_off">无日历同步(不推荐)</string>
|
||||
<string name="permissions_calendar_status_on">可同步日历</string>
|
||||
<string name="permissions_notification_title">通知权限</string>
|
||||
<string name="permissions_notification_status_off">已禁用通知(不推荐)</string>
|
||||
<string name="permissions_notification_status_on">已启用通知</string>
|
||||
<string name="permissions_jtx_title">jtx Board 权限</string>
|
||||
<string name="permissions_opentasks_title">OpenTasks权限</string>
|
||||
<string name="permissions_tasksorg_title">Tasks权限</string>
|
||||
<string name="permissions_tasks_status_off">无任务同步</string>
|
||||
<string name="permissions_tasks_status_on">可同步任务</string>
|
||||
<string name="permissions_autoreset_title">保留权限</string>
|
||||
<string name="permissions_autoreset_status_off">权限可能被自动重置(不推荐)</string>
|
||||
<string name="permissions_autoreset_status_on">权限不会被自动重置</string>
|
||||
<string name="permissions_autoreset_instruction">点击权限 > 取消选择 “移除权限,如果应用未使用”</string>
|
||||
<string name="permissions_app_settings_hint">如果切换没有正常工作,请使用应用程序设置/权限</string>
|
||||
<string name="permissions_app_settings">应用设置</string>
|
||||
<!--WifiPermissionsActivity-->
|
||||
<string name="wifi_permissions_label">WiFi SSID权限</string>
|
||||
<string name="wifi_permissions_intro">要访问当前的WiFi名称(SSID),必须满足以下条件: </string>
|
||||
<string name="wifi_permissions_location_permission">精确位置权限</string>
|
||||
<string name="wifi_permissions_location_permission_on">已授予位置权限</string>
|
||||
<string name="wifi_permissions_location_permission_off">位置权限被拒</string>
|
||||
<string name="wifi_permissions_background_location_permission">后台位置权限</string>
|
||||
<string name="wifi_permissions_background_location_permission_label">始终允许</string>
|
||||
<string name="wifi_permissions_background_location_permission_on">位置权限已设为:%s</string>
|
||||
<string name="wifi_permissions_background_location_permission_off">位置权限未设为:%s</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer">%s 使用位置数据 (仅 WiFi SSID) 的目的只是为了将同步限制到特定的 WiFi SSID。即使当同步在后台运行时,这也会发生。</string>
|
||||
<string name="wifi_permissions_background_location_disclaimer2">所有位置数据(仅 WiFi SSID)只在本地使用,不会被发送到任何地方。</string>
|
||||
<string name="wifi_permissions_location_enabled">始终允许定位</string>
|
||||
<string name="wifi_permissions_location_enabled_on">位置服务已启用</string>
|
||||
<string name="wifi_permissions_location_enabled_off">位置服务已禁用</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_translations">翻译</string>
|
||||
<string name="about_libraries">程序库</string>
|
||||
<string name="about_version">版本 %1$s (%2$d)</string>
|
||||
<string name="about_copyright">©Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) 及贡献者</string>
|
||||
<string name="about_license_info_no_warranty">本程序不附带任何担保。这是一款自由软件,你可以有条件地传播它。</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_couldnt_create_file">无法创建日志文件</string>
|
||||
<string name="logging_notification_text">正记录%s的所有活动</string>
|
||||
<string name="logging_notification_view_share">查看/分享</string>
|
||||
<string name="logging_notification_disable">禁用</string>
|
||||
<!--AccountsScreen-->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV 同步器</string>
|
||||
<string name="navigation_drawer_about">关于 / 许可</string>
|
||||
<string name="navigation_drawer_beta_feedback">测试版反馈</string>
|
||||
<string name="install_browser">请安装网页浏览器</string>
|
||||
<string name="navigation_drawer_settings">设置</string>
|
||||
<string name="navigation_drawer_news_updates">最新消息</string>
|
||||
<string name="navigation_drawer_tools">工具</string>
|
||||
<string name="navigation_drawer_external_links">外部链接</string>
|
||||
<string name="navigation_drawer_website">应用网站</string>
|
||||
<string name="navigation_drawer_manual">手册</string>
|
||||
<string name="navigation_drawer_faq">常见问题</string>
|
||||
<string name="navigation_drawer_community">社区</string>
|
||||
<string name="navigation_drawer_support_project">支持项目</string>
|
||||
<string name="navigation_drawer_contribute">如何作贡献</string>
|
||||
<string name="navigation_drawer_privacy_policy">隐私政策</string>
|
||||
<string name="account_list_no_notification_permission">已禁用通知。你将不会收到同步出错的通知</string>
|
||||
<string name="account_list_no_internet">自动同步不活跃(无已验证的互联网连接)</string>
|
||||
<string name="account_list_manage_connections">管理连接</string>
|
||||
<string name="account_list_datasaver_enabled">启用了流量节省程序。后台同步受限</string>
|
||||
<string name="account_list_manage_datasaver">管理流量节省程序</string>
|
||||
<string name="account_list_battery_saver_enabled">启用了节电程序。同步可能受限。</string>
|
||||
<string name="account_list_manage_battery_saver">管理节电程序</string>
|
||||
<string name="account_list_low_storage">低存储空间。Android 不会立即同步本地更改,但会在下次定期同步时进行</string>
|
||||
<string name="account_list_manage_storage">管理存储</string>
|
||||
<string name="account_list_calendar_storage_disabled">缺少日历提供程序。你停用了“日历存储”系统应用吗?</string>
|
||||
<string name="account_list_contacts_storage_disabled">缺少联系人提供程序。你停用了“联系人存储”系统应用吗?</string>
|
||||
<string name="account_list_manage_apps">管理应用</string>
|
||||
<string name="account_list_welcome">欢迎来到 DAVx⁵!</string>
|
||||
<string name="account_list_empty">连接到你的服务器,保持日历和联系人同步</string>
|
||||
<string name="accounts_sync_all">同步所有账户</string>
|
||||
<!--RefreshCollectionsWorker-->
|
||||
<string name="refresh_collections_worker_refresh_failed">服务配置检测失败</string>
|
||||
<string name="refresh_collections_worker_refresh_couldnt_refresh">无法刷新集合列表</string>
|
||||
<!--Foreground service used by WorkManager on Android <12-->
|
||||
<string name="foreground_service_notify_title">运行于前台</string>
|
||||
<string name="foreground_service_notify_text">在某些设备上,这是自动同步所必需的。 </string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">设置</string>
|
||||
<string name="app_settings_debug">调试</string>
|
||||
<string name="app_settings_show_debug_info">显示调试信息</string>
|
||||
<string name="app_settings_show_debug_info_details">查看/分享配置详情和日志</string>
|
||||
<string name="app_settings_logging">记录完整日志</string>
|
||||
<string name="app_settings_logging_on">日志记录处于活跃状态。你可以将日志作为调试信息的一部分来查看</string>
|
||||
<string name="app_settings_logging_off">日志记录已禁用</string>
|
||||
<string name="app_settings_battery_optimization">电池优化</string>
|
||||
<string name="app_settings_battery_optimization_exempted">排除本应用(推荐)</string>
|
||||
<string name="app_settings_battery_optimization_optimized">施加电池限制(不推荐)</string>
|
||||
<string name="app_settings_connection">连接</string>
|
||||
<string name="app_settings_proxy">代理类型</string>
|
||||
<string-array name="app_settings_proxy_types">
|
||||
<item>系统默认</item>
|
||||
<item>无代理</item>
|
||||
<item>HTTP</item>
|
||||
<item>SOCKS (用于 Orbot)</item>
|
||||
</string-array>
|
||||
<string name="app_settings_proxy_host">代理主机名称</string>
|
||||
<string name="app_settings_proxy_port">代理端口</string>
|
||||
<string name="app_settings_security">安全</string>
|
||||
<string name="app_settings_security_app_permissions">应用权限</string>
|
||||
<string name="app_settings_security_app_permissions_summary">查看同步所需权限</string>
|
||||
<string name="app_settings_distrust_system_certs">不信任系统证书</string>
|
||||
<string name="app_settings_distrust_system_certs_on">系统和用户增加的发布者不会被信任</string>
|
||||
<string name="app_settings_distrust_system_certs_off">系统和用户增加的发布者会被信任(推荐)</string>
|
||||
<string name="app_settings_reset_certificates">重设证书信任状态</string>
|
||||
<string name="app_settings_reset_certificates_summary">重设所有自定义证书的信任状态</string>
|
||||
<string name="app_settings_reset_certificates_success">所有自定义证书已清除</string>
|
||||
<string name="app_settings_user_interface">用户界面</string>
|
||||
<string name="app_settings_notification_settings">通知设置</string>
|
||||
<string name="app_settings_notification_settings_summary">管理通知渠道等设置</string>
|
||||
<string name="app_settings_theme_title">选择主题</string>
|
||||
<string-array name="app_settings_theme_names">
|
||||
<item>系统默认</item>
|
||||
<item>浅色</item>
|
||||
<item>深色</item>
|
||||
</string-array>
|
||||
<string name="app_settings_reset_hints">重设提示</string>
|
||||
<string name="app_settings_reset_hints_summary">重新显示之前忽略过的提示</string>
|
||||
<string name="app_settings_reset_hints_success">所有提示将会再次显示</string>
|
||||
<string name="app_settings_integration">集成</string>
|
||||
<string name="app_settings_tasks_provider">Tasks 应用</string>
|
||||
<string name="app_settings_tasks_provider_none">未找到兼容的任务应用</string>
|
||||
<string name="app_settings_unifiedpush">UnifiedPush (实验性)</string>
|
||||
<string name="app_settings_unifiedpush_disable">无(停用推送)</string>
|
||||
<string name="app_settings_unifiedpush_choose_distributor">选择分发程序</string>
|
||||
<string name="app_settings_unifiedpush_no_distributor">未安装推送分发程序</string>
|
||||
<string name="app_settings_unifiedpush_no_endpoint">未配置端点</string>
|
||||
<string name="app_settings_unifiedpush_ready">准备好通过 %s 接收推送消息</string>
|
||||
<!--AccountScreen-->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_missing_permissions">需要额外权限来同步这些集合</string>
|
||||
<string name="account_manage_permissions">管理权限</string>
|
||||
<string name="account_synchronize_now"> 立即同步</string>
|
||||
<string name="account_settings">账户设置</string>
|
||||
<string name="account_rename">重命名账户</string>
|
||||
<string name="account_rename_new_name_description">未保存的本地数据可能会消失。重命名后需要重新同步。</string>
|
||||
<string name="account_rename_new_name">新账户名</string>
|
||||
<string name="account_rename_rename">重命名</string>
|
||||
<string name="account_rename_exists_already">账户名已被占用</string>
|
||||
<string name="account_rename_couldnt_rename">无法重命名账户</string>
|
||||
<string name="account_delete">删除账户</string>
|
||||
<string name="account_delete_confirmation_title">真的要删除账户吗?</string>
|
||||
<string name="account_delete_confirmation_text">所有通讯录、日历和任务列表的本机存储将被删除。</string>
|
||||
<string name="account_synchronize_this_collection">同步该集合</string>
|
||||
<string name="account_read_only">只读</string>
|
||||
<string name="account_calendar">日历</string>
|
||||
<string name="account_contacts">联系人</string>
|
||||
<string name="account_journal">日记</string>
|
||||
<string name="account_task_list">任务</string>
|
||||
<string name="account_only_personal">只显示个人</string>
|
||||
<string name="account_refresh_collections">刷新列表</string>
|
||||
<string name="account_webcal_external_app">可以用外部应用来同步 Webcal 订阅</string>
|
||||
<string name="account_no_webcal_handler_found">找不到支持 Webcal 的应用</string>
|
||||
<string name="account_install_icsx5">安装 ICSx⁵</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">增加账户</string>
|
||||
<string name="login_privacy_hint"><![CDATA[所有数据只会在你的服务器和设备之间传输。%1$s不会把它们发送到任何其他地方。 参见 <a href="%2$s">隐私政策</a>。]]></string>
|
||||
<string name="login_generic_login">常规登录</string>
|
||||
<string name="login_provider_login">特定服务商的登录</string>
|
||||
<string name="login_continue">继续</string>
|
||||
<string name="login_login">登录</string>
|
||||
<string name="login_type_email">使用邮箱地址登录</string>
|
||||
<string name="login_email_address">Email 地址</string>
|
||||
<string name="login_email_address_error">请输入有效 Email 地址</string>
|
||||
<string name="login_email_address_info"><![CDATA[该邮件域被用作基URL。<a href="%s">服务发现</a> 通过 DNS 记录和已知URLs 进行。]]></string>
|
||||
<string name="login_password">密码</string>
|
||||
<string name="login_password_hide">隐藏密码</string>
|
||||
<string name="login_password_show">显示密码</string>
|
||||
<string name="login_password_optional">密码*</string>
|
||||
<string name="login_type_url">使用 URL 和用户名登录</string>
|
||||
<string name="login_user_name">用户名</string>
|
||||
<string name="login_user_name_optional">用户名*</string>
|
||||
<string name="login_base_url">根地址</string>
|
||||
<string name="login_base_url_info"><![CDATA[此基URL将被直接检查,但 <a href="%s">服务发现也将</a>使用 DNS 记录 和已知 URLs 进行。]]></string>
|
||||
<string name="login_select_certificate">选择证书</string>
|
||||
<string name="login_add_account">增加账户</string>
|
||||
<string name="login_account_name">账户显示名</string>
|
||||
<string name="login_account_avoid_apostrophe">使用撇号(\')似乎会在一些设备上造成问题</string>
|
||||
<string name="login_account_name_info">请使用你的邮箱地址作为帐户名,因为 Android 会将你创建的日历事件的创建者项设置为帐户名。你不能拥有多个帐户名相同的账户。</string>
|
||||
<string name="login_account_contact_group_method">联系人分组方式</string>
|
||||
<string name="login_account_name_required">请输入账户名</string>
|
||||
<string name="login_account_name_already_taken">账户名已被占用</string>
|
||||
<string name="login_account_not_added">无法添加账户</string>
|
||||
<string name="login_finish">完成</string>
|
||||
<string name="login_type_advanced">高级登录</string>
|
||||
<string name="login_no_client_certificate_optional">无客户端证书*</string>
|
||||
<string name="login_client_certificate_selected">客户端证书:%s</string>
|
||||
<string name="login_no_certificate_found">没有找到证书</string>
|
||||
<string name="login_install_certificate">安装证书</string>
|
||||
<string name="login_type_google">Google 联系人/日历</string>
|
||||
<string name="login_google_see_tested_with">请参阅我们的“Tested with”页面的 Google 部分获得最新信息。</string>
|
||||
<string name="login_google_unexpected_warnings">你可能遇到意外的警告和/或者不得不创建自己的 client ID。</string>
|
||||
<string name="login_google_account">Google 账户</string>
|
||||
<string name="login_google">使用 Google 账户登录</string>
|
||||
<string name="login_google_client_id">Client ID (可选)</string>
|
||||
<string name="login_google_client_privacy_policy"><![CDATA[%1$s传输你的 Google 联系人和日历数据的目的仅是为了与此设备同步。详情见我们的 <a href="%2$s">隐私政策</a> 。]]></string>
|
||||
<string name="login_google_client_limited_use"><![CDATA[%1$s遵守 <a href="%2$s">Google API 服务用户数据政策</a>,包括有限使用的要求。]]></string>
|
||||
<string name="login_oauth_couldnt_obtain_auth_code">无法获得身份验证码</string>
|
||||
<string name="login_type_nextcloud">Nextcloud</string>
|
||||
<string name="login_nextcloud_login_with_nextcloud">用 Nextcloud 登录</string>
|
||||
<string name="login_nextcloud_login_flow_text">这会在网页浏览器中开启 Nextcloud 登录流程</string>
|
||||
<string name="login_nextcloud_login_flow_server_address">Nextcloud 服务器地址</string>
|
||||
<string name="login_nextcloud_login_flow_sign_in">登录</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_url">无法获取登录 URL</string>
|
||||
<string name="login_nextcloud_login_flow_no_login_data">无法获得登陆数据</string>
|
||||
<string name="login_configuration_detection">正在配置</string>
|
||||
<string name="login_querying_server">正在与服务器通信,请稍等…</string>
|
||||
<string name="login_no_service">找不到 CalDAV 或 CardDAV 服务。</string>
|
||||
<string name="login_no_service_info">基URL似乎不是可访问的CalDAV/CardDAV URL 且服务检测不成功。</string>
|
||||
<string name="login_see_tested_services"><![CDATA[请查看服务供应商手册和 <a href="%s">我们的已测试服务列表</a> 及它们的基础 URLs.]]></string>
|
||||
<string name="login_check_credentials">也请仔细核查身份验证数据(通常是用户名和密码)。</string>
|
||||
<string name="login_logs_available">可以在日志中看到进一步的技术信息</string>
|
||||
<string name="login_view_logs">查看日志</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_sync">同步</string>
|
||||
<string name="settings_sync_interval_contacts">通讯录自动同步间隔</string>
|
||||
<string name="settings_sync_summary_manually">手动同步</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">每 %d 分钟或本地修改后</string>
|
||||
<string name="settings_sync_interval_calendars">日历自动同步间隔</string>
|
||||
<string name="settings_sync_interval_tasks">任务自动同步间隔</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>手动同步</item>
|
||||
<item>每 15 分钟</item>
|
||||
<item>每 30 分钟</item>
|
||||
<item>每小时</item>
|
||||
<item>每 2 小时</item>
|
||||
<item>每 4 小时</item>
|
||||
<item>每天一次</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">只在 WiFi 下同步</string>
|
||||
<string name="settings_sync_wifi_only_on">同步只在 WiFi 连接下进行</string>
|
||||
<string name="settings_sync_wifi_only_off">同步不受数据连接类型限制</string>
|
||||
<string name="settings_sync_wifi_only_ssids">WiFi SSID 限制</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">只使用 %s 网络同步</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">任意 WiFi 网络均可同步</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">请用半角逗号分隔允许同步的 WiFi 网络名(SSID),留空则允许任意网络</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_required">WiFi SSID 限制需要进一步设置</string>
|
||||
<string name="settings_sync_wifi_only_ssids_permissions_action">管理</string>
|
||||
<string name="settings_ignore_vpns">VPN 需要底层互联网</string>
|
||||
<string name="settings_ignore_vpns_on">没有底层验证的互联网连接的 VPN 不足以运行同步(推荐选项)</string>
|
||||
<string name="settings_ignore_vpns_off">没有底层验证的互联网连接的 VPN 足以运行同步了</string>
|
||||
<string name="settings_authentication">认证</string>
|
||||
<string name="settings_username">用户名</string>
|
||||
<string name="settings_password">密码</string>
|
||||
<string name="settings_new_password">新密码</string>
|
||||
<string name="settings_password_summary">修改服务器密码</string>
|
||||
<string name="settings_certificate_alias">客户端证书</string>
|
||||
<string name="settings_certificate_alias_empty">无证书可用或未选择证书</string>
|
||||
<string name="settings_certificate_install">安装证书</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">旧日程时间限制</string>
|
||||
<string name="settings_sync_time_range_past_none">同步所有日程</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="other">%d 天前的日程不会被同步</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">超过这个数字的天数的旧日程将会被忽略(可以为 0)。留空则同步所有日程。</string>
|
||||
<string name="settings_default_alarm">默认提醒</string>
|
||||
<plurals name="settings_default_alarm_on">
|
||||
<item quantity="other">默认事件开始前 %d 分钟提醒</item>
|
||||
</plurals>
|
||||
<string name="settings_default_alarm_off">默认提醒未创建</string>
|
||||
<string name="settings_default_alarm_message">当没有提醒的事件需增加默认提醒时,事件开始前多少分钟触发提醒。留空以禁用默认提醒。</string>
|
||||
<string name="settings_manage_calendar_colors">管理日历颜色</string>
|
||||
<string name="settings_manage_calendar_colors_on">日历的颜色会在每次同步时被重置 </string>
|
||||
<string name="settings_manage_calendar_colors_off">日历的颜色可以由其他应用程序设置 </string>
|
||||
<string name="settings_event_colors">事件日历颜色支持</string>
|
||||
<string name="settings_event_colors_on">事件颜色已同步</string>
|
||||
<string name="settings_event_colors_off">事件颜色未同步</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">联系人分组方式</string>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>按 VCard 文件分组</item>
|
||||
<item>按联系人分类分组</item>
|
||||
</string-array>
|
||||
<!--CreateAddressBookScreen, CreateCalendarScreen-->
|
||||
<string name="create_addressbook">创建通讯录</string>
|
||||
<string name="create_addressbook_maybe_not_supported">服务器可能不支持通过 CalDAV 创建通讯录</string>
|
||||
<string name="create_calendar">创建日历</string>
|
||||
<string name="create_calendar_time_zone_optional">默认时区*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">可能使用的日历类型</string>
|
||||
<string name="create_calendar_type_vevent">事件</string>
|
||||
<string name="create_calendar_type_vtodo">任务</string>
|
||||
<string name="create_calendar_type_vjournal">笔记 / 日志</string>
|
||||
<string name="create_calendar_maybe_not_supported">服务器可能不支持通过 CalDAV 创建日历</string>
|
||||
<string name="create_collection_color">颜色</string>
|
||||
<string name="create_collection_display_name">标题</string>
|
||||
<string name="create_collection_home_set">存储位置</string>
|
||||
<string name="create_collection_description_optional">描述</string>
|
||||
<string name="create_collection_create">创建</string>
|
||||
<string name="create_collection_optional">* 可选</string>
|
||||
<!--CollectionScreen-->
|
||||
<string name="collection_delete">删除集合</string>
|
||||
<string name="collection_delete_warning">此集合(%s)及其所有数据将从本地和服务器被永久删除</string>
|
||||
<string name="collection_synchronization">同步</string>
|
||||
<string name="collection_synchronization_on">同步已启用</string>
|
||||
<string name="collection_synchronization_off">已停用同步</string>
|
||||
<string name="collection_read_only">只读</string>
|
||||
<string name="collection_read_only_by_server">只读(服务器)</string>
|
||||
<string name="collection_read_only_by_setting">只读(设置决定)</string>
|
||||
<string name="collection_read_only_forced">只读 (仅本地)</string>
|
||||
<string name="collection_read_write">读/写</string>
|
||||
<string name="collection_title">标题</string>
|
||||
<string name="collection_description">描述</string>
|
||||
<string name="collection_owner">所有者</string>
|
||||
<string name="collection_push_support">推送支持</string>
|
||||
<string name="collection_push_web_push">服务器宣告推送支持</string>
|
||||
<string name="collection_push_subscribed_at">订阅于 %1$s,过期于 %2$s</string>
|
||||
<string name="collection_last_sync">上次同步(%s)</string>
|
||||
<string name="collection_url">地址(URL)</string>
|
||||
<!--debugging and DebugInfoActivity-->
|
||||
<string name="debug_info_title">调试信息</string>
|
||||
<string name="debug_info_archive_caption">ZIP 压缩文件</string>
|
||||
<string name="debug_info_archive_subtitle">包含调试信息和日志</string>
|
||||
<string name="debug_info_archive_text">共享压缩文件以将其传输到计算机上,通过电子邮件发送或将其附加到支持请求。</string>
|
||||
<string name="debug_info_archive_share">分享压缩文件</string>
|
||||
<string name="debug_info_attached">已附加调试信息到此消息(需要接收应用支持附件功能)</string>
|
||||
<string name="debug_info_http_error">HTTP错误</string>
|
||||
<string name="debug_info_server_error">服务器错误</string>
|
||||
<string name="debug_info_webdav_error">WebDAV错误</string>
|
||||
<string name="debug_info_io_error">I/O错误</string>
|
||||
<string name="debug_info_http_403_description">该请求已被拒绝。 请检查涉及的资源和调试信息,以了解详情。</string>
|
||||
<string name="debug_info_http_404_description">所请求的资源不再存在。请检查涉及的资源和调试信息,以了解详情。</string>
|
||||
<string name="debug_info_http_5xx_description">发生服务器端问题。 请联系您的服务器支持</string>
|
||||
<string name="debug_info_unexpected_error">发生意外错误。 查看调试信息以获取详细信息。</string>
|
||||
<string name="debug_info_view_details">查看细节</string>
|
||||
<string name="debug_info_subtitle">已收集调试信息</string>
|
||||
<string name="debug_info_involved_caption">所涉资源</string>
|
||||
<string name="debug_info_involved_subtitle">与此问题有关</string>
|
||||
<string name="debug_info_involved_remote">远程资源:</string>
|
||||
<string name="debug_info_involved_local">本地资源:</string>
|
||||
<string name="debug_info_logs_caption">日志</string>
|
||||
<string name="debug_info_logs_subtitle">详细日志可用</string>
|
||||
<string name="debug_info_logs_view">查看日志</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">出现错误</string>
|
||||
<string name="exception_httpexception">出现 HTTP 错误</string>
|
||||
<string name="exception_ioexception">出现 I/O 错误</string>
|
||||
<string name="exception_show_details">显示详情</string>
|
||||
<!--WebDAV accounts-->
|
||||
<string name="webdav_mounts_title">WebDAV 文件系统</string>
|
||||
<string name="webdav_mounts_quota_used_available">已用配额:%1$s/可用容量:%2$s</string>
|
||||
<string name="webdav_mounts_share_content">分享内容</string>
|
||||
<string name="webdav_mounts_unmount">解除挂载</string>
|
||||
<string name="webdav_add_mount_title">添加 WebDAV 文件系统</string>
|
||||
<string name="webdav_mounts_empty">通过添加 WebDAV 挂载直接访问您的云文件!</string>
|
||||
<string name="webdav_add_mount_empty_more_info"><![CDATA[查看<a href="%1$s">WebDAV 工作方式</a>的手册。</string>]]></string>
|
||||
<string name="webdav_add_mount_display_name">展示名称</string>
|
||||
<string name="webdav_add_mount_url">WebDAV URL</string>
|
||||
<string name="webdav_add_mount_url_invalid">无效 URL</string>
|
||||
<string name="webdav_add_mount_authentication">身份验证(可选)</string>
|
||||
<string name="webdav_add_mount_username">用户名</string>
|
||||
<string name="webdav_add_mount_password">密码</string>
|
||||
<string name="webdav_add_mount_add">添加 WebDAV 网址</string>
|
||||
<string name="webdav_add_mount_no_support">此 URL 无 WebDAV 服务</string>
|
||||
<string name="webdav_remove_mount_title">删除装载点</string>
|
||||
<string name="webdav_remove_mount_text">将丢失连接详情,但不会删除文件</string>
|
||||
<string name="webdav_notification_access">正在访问 WebDAV 文件</string>
|
||||
<string name="webdav_notification_download">正在下载 WebDAV 文件</string>
|
||||
<string name="webdav_notification_upload">正在上传 WebDAV 文件</string>
|
||||
<string name="webdav_provider_root_title">WebDAV 文件系统</string>
|
||||
<!--sync-->
|
||||
<string name="sync_error_permissions">DAVx⁵ 权限</string>
|
||||
<string name="sync_error_permissions_text">需要额外权限</string>
|
||||
<string name="sync_error_tasks_too_old">%s太旧</string>
|
||||
<string name="sync_error_tasks_required_version">最低要求版本: %1$s</string>
|
||||
<string name="sync_error_authentication_failed">认证失败(请检查登录凭据,如用户名密码)</string>
|
||||
<string name="sync_error_io">网络或 I/O 错误 – %s</string>
|
||||
<string name="sync_error_http_dav">HTTP 服务器错误 – %s</string>
|
||||
<string name="sync_error_local_storage">本地存储错误 – %s</string>
|
||||
<string name="sync_error_retry_limit_reached">软错误(达到最大重试次数)</string>
|
||||
<string name="sync_error_view_item">显示项目</string>
|
||||
<string name="sync_invalid_contact">从服务器收到无效的通讯录</string>
|
||||
<string name="sync_invalid_event">从服务器收到无效的日历事件</string>
|
||||
<string name="sync_invalid_task">从服务器收到无效的任务项</string>
|
||||
<string name="sync_invalid_resources_ignoring">正在忽略若干无效资源</string>
|
||||
<string name="sync_notification_pending_push_title">待同步</string>
|
||||
<string name="sync_notification_pending_push_message">远程数据已更改</string>
|
||||
<!--widgets-->
|
||||
<string name="widget_sync_all">同步所有</string>
|
||||
<string name="widget_sync_all_accounts">同步所有账户</string>
|
||||
<!--cert4android-->
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
<certificates src="user" tools:ignore="AcceptsUserCertificates" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -1,6 +0,0 @@
|
||||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:contentAuthority="com.android.calendar"
|
||||
android:accountType="@string/account_type"
|
||||
android:userVisible="false"
|
||||
android:supportsUploading="true"
|
||||
android:allowParallelSyncs="true" />
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class PlainTextFormatterTest {
|
||||
|
||||
private val minimum = PlainTextFormatter(
|
||||
withTime = false,
|
||||
withSource = false,
|
||||
withException = false,
|
||||
lineSeparator = null
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_format_param_null() {
|
||||
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
|
||||
parameters = arrayOf(null)
|
||||
})
|
||||
assertEquals("Message\n\tPARAMETER #1 = (null)", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_format_param_object() {
|
||||
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
|
||||
parameters = arrayOf(object {
|
||||
override fun toString() = "SomeObject[]"
|
||||
})
|
||||
})
|
||||
assertEquals("Message\n\tPARAMETER #1 = SomeObject[]", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_format_truncatesMessage() {
|
||||
val result = minimum.format(LogRecord(Level.INFO, "a".repeat(50000)))
|
||||
// PlainTextFormatter.MAX_LENGTH is 10,000
|
||||
assertEquals(10000, result.length)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
|
||||
class PushMessageParserTest {
|
||||
|
||||
private val parse = PushMessageParser(logger = Logger.getGlobal())
|
||||
|
||||
@Test
|
||||
fun testInvalidXml() {
|
||||
assertNull(parse("Non-XML content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWithXmlDeclAndTopic() {
|
||||
val topic = parse(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>" +
|
||||
"<P:push-message xmlns:D=\"DAV:\" xmlns:P=\"https://bitfire.at/webdav-push\">" +
|
||||
" <D:propstat>" +
|
||||
" <D:prop>" +
|
||||
" <P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>" +
|
||||
" <D:sync-token>http://example.com/ns/sync/1234</D:sync-token>" +
|
||||
" </D:prop>" +
|
||||
" </D:propstat>" +
|
||||
"</P:push-message>"
|
||||
)
|
||||
assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.mikepenz.aboutLibraries) apply false
|
||||
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android) apply false
|
||||
}
|
||||
0
app/.gitignore → core/.gitignore
vendored
0
app/.gitignore → core/.gitignore
vendored
@@ -1,30 +1,22 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.mikepenz.aboutLibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404060003
|
||||
versionName = "4.4.6"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 35 // Android 15
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
@@ -50,11 +42,20 @@ android {
|
||||
// Java namespace for our classes (not to be confused with Android package ID)
|
||||
namespace = "at.bitfire.davdroid"
|
||||
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("ose") {
|
||||
dimension = "distribution"
|
||||
versionNameSuffix = "-ose"
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// multiple (test) dependencies have LICENSE files at same location
|
||||
merges += arrayOf("META-INF/LICENSE*")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,46 +65,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules-release.pro")
|
||||
|
||||
isShrinkResources = true
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos", "NullSafeMutableLiveData")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += arrayOf("META-INF/*.md")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
@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"
|
||||
}
|
||||
@@ -117,22 +86,14 @@ ksp {
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
excludeFields = arrayOf("generated")
|
||||
}
|
||||
|
||||
configurations {
|
||||
configureEach {
|
||||
// exclude modules which are in conflict with system libraries
|
||||
exclude(module="commons-logging")
|
||||
exclude(group="org.json", module="json")
|
||||
|
||||
// Groovy requires SDK 26+, and it's not required, so exclude it
|
||||
exclude(group="org.codehaus.groovy")
|
||||
export {
|
||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||
excludeFields.add("generated")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// core
|
||||
// Kotlin / Android
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
@@ -160,45 +121,62 @@ dependencies {
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(libs.compose.accompanist.permissions)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.runtime.livedata)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.toolingPreview)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.materialIconsExtended)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
||||
|
||||
// Glance Widgets
|
||||
implementation(libs.glance.base)
|
||||
implementation(libs.glance.material)
|
||||
implementation(libs.androidx.glance.base)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
// Jetpack Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.base)
|
||||
implementation(libs.room.paging)
|
||||
ksp(libs.room.compiler)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.base)
|
||||
implementation(libs.androidx.room.paging)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// own libraries
|
||||
implementation(libs.bitfire.cert4android)
|
||||
implementation(libs.bitfire.dav4jvm) {
|
||||
exclude(group="junit")
|
||||
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
|
||||
}
|
||||
implementation(libs.bitfire.synctools) {
|
||||
exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code
|
||||
exclude(group = "junit")
|
||||
}
|
||||
implementation(libs.bitfire.ical4android)
|
||||
implementation(libs.bitfire.vcard4android)
|
||||
|
||||
// third-party libs
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.openid.appauth)
|
||||
implementation(libs.unifiedpush)
|
||||
implementation(libs.unifiedpush) {
|
||||
// UnifiedPush connector seems to be using a workaround by importing this library.
|
||||
// Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
|
||||
// See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59
|
||||
exclude(group = "com.google.crypto.tink", module = "tink")
|
||||
}
|
||||
implementation(libs.unifiedpush.fcm)
|
||||
|
||||
// force some versions for compatibility with our minSdk level (see version catalog for details)
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.lang)
|
||||
|
||||
// for tests
|
||||
androidTestImplementation(libs.androidx.arch.core.testing)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
@@ -206,11 +184,13 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.junit)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||
androidTestImplementation(libs.room.testing)
|
||||
|
||||
testImplementation(libs.bitfire.dav4jvm)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
31
core/proguard-rules-release.pro
Normal file
31
core/proguard-rules-release.pro
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
# 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
|
||||
|
||||
# keep rules
|
||||
-keep class at.bitfire.** { *; } # all DAVx5 code is required
|
||||
-keep class org.xmlpull.** { *; }
|
||||
|
||||
# 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 org.xmlpull.**
|
||||
|
||||
# dnsjava
|
||||
-dontwarn com.sun.jna.**
|
||||
-dontwarn lombok.**
|
||||
-dontwarn javax.naming.NamingException
|
||||
-dontwarn javax.naming.directory.**
|
||||
-dontwarn sun.net.spi.nameservice.NameService
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider
|
||||
|
||||
# okhttp
|
||||
# https://github.com/bitfireAT/davx5/issues/711 / https://github.com/square/okhttp/issues/8574
|
||||
-keep class okhttp3.internal.idn.IdnaMappingTable { *; }
|
||||
-keep class okhttp3.internal.idn.IdnaMappingTableInstanceKt{ *; }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user